Creating TypeScript type guards with Yup

15 November, 2021

In TypeScript, a type guard is a way to narrow the type of a value. TypeScript has many of these built in to the language. For example:

// doSomething takes a single parameter that is either a string or a number.
function doSomething(x: string | number) {
  // This is a type guard that narrows the type of x to only a string.
  if (typeof x === "string") {
    return x.toUpperCase();
  }
  // Since the conditional block always returns, the type of x here is narrowed
  // to only a number.
  return x.toPrecision(2);
}

You can also define your own type guard by implementing a function whose return type is a type predicate. The example from the TypeScript documentation is a good one:

// This is our custom type guard. It takes an argument that is either a Fish or
// a Bird and reports whether that argument can be narrowed to the Fish type.
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

Using a user-defined type guard works the same as any other type guard.

// pet has the type Fish | Bird.
let pet = getSmallPet();

if (isFish(pet)) {
  // The isFish type guard narrows the type of pet to only a Fish in this block.
  pet.swim();
} else {
  // Same as before, the opposite narrowing also happens here.
  pet.fly();
}

Yup schemas

Yup is a JavaScript library for creating data schemas that can be used to parse and validate values. You can define the shape of an object using a functional programming style API. Here is an example from their home page:

import * as yup from "yup";

let userSchema = yup.object().shape({
  name: yup.string().required(),
  age: yup.number().required().positive().integer(),
  email: yup.string().email(),
  website: yup.string().url(),
  createdOn: yup.date().default(function () {
    return new Date();
  }),
});

This creates a schema for an object that defines a user. Yup schemas have methods that can, among other things, validate whether an object satisfies the schema. For example:

userSchema.isValidSync({
  name: "Sam Eagle",
  age: 47,
  email: "seagle@example.com",
  createdOn: new Date("1974-12-10"),
}); // true

userSchema.isValidSync({
  something: "foo",
  notAName: "bar",
}); // false

Putting them together

Here is our task: we have some data that has the union type User | Admin, and we want to greet them differently depending on the type of data.

const data: User | Admin = getPersona();

if (isUser(data)) {
  console.log(`Hello, ${data.name}!`);
} else {
  console.log(`Welcome, ${data.salutation} ${data.lastName}.`);
}

Our goal is to implement the type guard isUser with the following requirements:

  • The User type is already defined (either by us or importing from elsewhere).
  • The validation is performed by a Yup schema.
  • TypeScript enforces that the Yup schema correctly validates a User type.

Here we go!

First, suppose this is the definition of a User:

interface User {
  name: string;
  age: number;
  email?: string;
  website?: string;
  createdOn: Date;
}

We will reuse our userSchema from before, but this time we will add a type annotation to it.

- const userSchema = yup.object().shape({
+ const userSchema: yup.SchemaOf<User> = yup.object().shape({
  name: yup.string().required(),
  age: yup.number().required().positive().integer(),
  email: yup.string().email(),
  website: yup.string().url(),
  createdOn: yup.date().default(function () {
    return new Date();
  }),
});

By adding the type assertion for yup.SchemaOf<User>, we will now have assurance that the schema definition agrees with our type definition. To see this in action, try removing one of the properties.

// ERROR: Property 'website' is missing in type '...'
const userSchema: yup.SchemaOf<User> = yup.object().shape({
  name: yup.string().required(),
  age: yup.number().required().positive().integer(),
  email: yup.string().email(),
  createdOn: yup.date().default(function () {
    return new Date();
  }),
});

Be warned: the Yup schema can be more strict than the type it’s meant to check for, such as requiring more properties on the object. It has to be able to do this (there is no TypeScript type for “positive integer”). What you are guaranteed is that it will check at least for the types you pass in.

Now, this is already pretty good, and calling userSchema.isValidSync(data) will narrow the type of data. However, it’s not quite perfect—the else branch is not narrowed!

const data: User | Admin = getPersona();

if (isUser(data)) {
  console.log(`Hello, ${data.name}!`);
} else {
  // ERROR: Property 'salutation' does not exist on type 'User'.
  console.log(`Welcome, ${data.salutation} ${data.lastName}.`);
}

Looking at the source code for isValidSync, we can see why this happens. The isValidSync method is a type guard for a different type: yup.Asserts<User>, which is assignable to our User type (hence why the if branch passes the type checker), but it is not our User type. This means that in the else branch, the type of data is still User | Admin.

To get around this, we still need to define our own type guard. Thankfully, all of the hard work has been done for us by Yup.

function isUser(value: unknown): value is User {
  return userSchema.isValidSync(value);
}

That’s it! Now our original example works. Here we put it all together:

import * as yup from "yup";
import type { User } from "./types";
import { getPersona } from "./persona";

const userSchema: yup.SchemaOf<User> = yup.object().shape({
  name: yup.string().required(),
  age: yup.number().required().positive().integer(),
  email: yup.string().email(),
  website: yup.string().url(),
  createdOn: yup.date().default(function () {
    return new Date();
  }),
});

function isUser(value: unknown): value is User {
  return userSchema.isValidSync(value);
}

const data: User | Admin = getPersona();

if (isUser(data)) {
  console.log(`Hello, ${data.name}!`);
} else {
  console.log(`Welcome, ${data.salutation} ${data.lastName}.`);
}