Type narrowing and discriminating unions in TypeScript

May 10, 2022

Type narrowing is the process of moving from a less precise set of types to a more precise type or set of types. The aim of type narrowing is to reduce the types down to only the ones where the following methods or properties can be correctly accessed or called without throwing errors.

It's a bit like playing a game of "Twenty questions".

In the traditional game, one player is chosen to be the answerer. That person chooses a subject but does not reveal this to the others. All other players are questioners. They each take turns asking a question which can be answered with a simple "yes" or "no". - https://en.wikipedia.org/wiki/Twenty_questions

Consider a variable that can be a string, number or Date.

type EventDate = string | Date | number;

function formatEvent(date: EventDate) {
  return date.toISOString() // Property 'toISOString' does not exist on type 'string'
}

If we want to format that variable, then we need to know what it is in order to do so. We need to apply some narrowing inside the function so that we can handle the different types which the variable can be.

function formatEvent(date: EventDate) {
    if (date instanceof Date) { // Check if this is an instance of date
      return date.toISOString() // Format and return
    } else if (typeof date === "number") { // Check if it's a number
      return new Date(date).toISOString() // Convert to date, format and return
    } else {
      return date // just return the string
    }
}

This also works with properties of types. Take the following function, which allows you to add a flavor to your snack.

type Sandwich = {
    filling: string[]; // A sandwich has flavors inside
}

type Pizza = {
    topping: string[]; // A pizza has flavors on top
}

type Snack = Sandwich | Pizza;

function addFlavor(flavor: string, snack: Snack) {
    snack.topping.push(flavor); // Property 'topping' does not exist on type 'Sandwich'.
}

Because we can pass a Sandwich or a Pizza to the addFlavor function, TypeScript throws an error when we try to access the .topping property because we could be trying to access a topping on a Sandwich which is not possible. In order to prevent this from happening we need to narrow the type down to just Pizza (or Snacks which can have a topping).

By using the values and properties of variables and objects we can narrow down the type until we get to one or more that we are targetting.

We can ask the question... Do you have a property of topping? And if the answer is "yes" then we perform our actions.

function addFlavor(flavor: string, snack: Snack) {
    // We can narrow the types of object that we are using by checking if a property exists before using it
    if ("topping" in snack) {
        snack.topping.push(flavor); // Only applies to Snacks with "toppings" (Pizza)
    } else {
        snack.filling.push(flavor) // Applies to all other Snacks (Sandwich)
    }
}

Now if we add a new type of snack then the same logic can apply

type Wrap = {
  filling: string[];
}

type Snack = Sandwich | Pizza | Wrap;

function addFlavor(flavor: string, snack: Snack) {
  // We can narrow the types of object that we are using by checking if a property exists before using it
  if ("topping" in snack) {
    snack.topping.push(flavor);
  } else {
    snack.filling.push(flavor) // This is now a Wrap or a Sandwich
  }
}

We can also use combinations of properties to discriminate between types, or use discrimination unions.

Discriminating unions

One way to narrow down the type on which TypeScript is working with is to add a literal field to all relevant types and use it to narrow down to a single type.

Because types are removed during the build step of TypeScript we can't use the type names directly, instead we have to add another property to use in order to distinguish between them at run time

type Tent = {
  accomodation: "tent"; // This is the "discriminant" field on all our types
}

type Yurt = {
  accomodation: "tent";
  rooms: number;
}

type Caravan = {
  accomodation: "caravan";
  wheels: number
}

type Camper = {
  accomodation: "campervan";
  wheels: number;
  rooms: number
}

type House = {
  accomodation: "house";
  rooms: number
}

type Accomodation = Tent | Yurt | Caravan | Camper | House;

function staySomewhere(place: Accomodation) {
  switch (place.accomodation) {
    case "tent":
       // "narrowed to tent types"
    case "caravan":
      // "narrowed to caravan types"
    case "camper":
      // "narrowed to camper types"
    case "house":
      // "narrowed to house types"
  }
}

By using a discriminating union pattern we have a good way to narrow our types easily, especially where there are many other common overlapping properties.


Tagged in: