Typescript · March 15, 2021

User-Defined Type Guards aren't safe

Thomas Lefebvre

JavaScript is a very dynamic language. The softwares we build deal with many different kinds of values or types. These values are set, passed to functions and possibly even mutated. It's hard to keep track sometimes and make sure we're passing the right stuff to our program. What would happen if we would give a number to a function that is expecting a string? Nothing good that's for sure.

TypeScript is designed to provide a layer of type safety to our program. It tries to keep track of these different kinds of values for us so we don't have to. As the business requirements evolve, the code we write becomes more complex and often requires us to deal with more complex types as well. Sometimes even, we may group values together under a common name, this is what we call a union type. Union types are great when dealing with collections of multiple types, like restaurant orders.

type Hotdog = {
  _tag: "Hotdog";
  sauces: Array<string>;
  isVegetarian: boolean;
}

type Burger = {
  _tag: "Burger";
  sauces: Array<string>;
  isVegetarian: boolean;
}

// This is a union type
type Food = Hotdog | Burger

const orders: Array<Food> = [..];

We know that each order is a type of Food but we don't really know if it's a Burger or a Hotdog. Our program needs to treat hotdogs and burgers differently, for instance for choosing the bread. Thankfully, we thought about adding a property _tag to differentiate them, to discriminate one over the other if you will. That is a technique called tagged unions. Now we're able to process our orders fairly easily by doing a check to see if we're currently dealing with a hotdog or a burger.

const orders: Array<Food> = [..];

orders.forEach(order => {
  // This is a type guard.
  if (order._tag === "Burger") {
    order; // Burger
  } else {
    order; // Hotdog
  }
})

Doing this check helps TypeScript to narrow the type of order and know that we're dealing with burgers when the _tag property is equal to "Burger". We've written a type guard. Type guards allow TypeScript to narrow the type of a union.

This previous type guard was inlined but what if we need to re-use this check elsewhere? Well, we extract it to a function but a special function called a User-Defined Type Guard (UDTG).

const isBurger =
  (food: Food): food is Burger => food._tag === "Hotdog";

orders.forEach(order => {
  // This is a type guard.
  if (isBurger(order)) {
    order; // Burger
  } else {
    order; // Hotdog
  }
})

All is well, we're processing orders and the restaurant is happy...

Hu ho. Actually, did you notice we've made a mistake in our UDTG? We're checking if the _tag is "Hotdog" but we want to check if it's a "Burger". Well TypeScript didn't complain and I'm coming back to the title of this post: User-Defined Type Guards aren't safe.

In TypeScript's mind everything is fine because the _tag property is a union of "Hotdog" and "Burger" so from a pure code standpoint, this is fine. In the real world though, this would cause a possible runtime exceptions by sending orders in the wrong place...One might think it's a bug in TypeScript but in fact it's a design choice. See the following Github issue to track it https://github.com/microsoft/TypeScript/issues/29980

Wouldn't it be great if TypeScript would also prevent us from making these possible human mistakes? Well there is a way and it doesn't involve that much more code. Here's a rewritten example of our isBurger type guard.

type Hotdog = {
  _tag: "Hotdog";
  sauces: Array<string>;
  isVegetarian: boolean;
}

type Burger = {
  _tag: "Burger";
  sauces: Array<string>;
  isVegetarian: boolean;
}


type Food = Hotdog | Burger

// This little helper provides a safer way to narrow the type.
const is = <A, B extends A>(fn: (a: A) => B | undefined) => {
  return (a: A): a is B => typeof fn(a) !== "undefined";
}

const isBurger = is<Food, Burger>(
  food => food._tag === "Burger" ? food : undefined
);

This addresses our issue because TypeScript is able to narrow the type inside the branch of the ternary where the condition is true. When we return our value, TypeScript will check the narrowed type matches the annotated return type, thereby preventing developers from accidentally introducing bugs

One downside of this approach is that it doesn't work if the union contains undefined as a possible value. To solve that issue at Unsplash we've been using Option.getRefinement from the fp-ts library.

By forcing developers to return the whole type or undefined we can ensure no typo or possible mistake can be made in the body of the type guard.

Share article