Narrowing Types in TypeScript

13 April 2022

Photo credit: Nika Benedictova

What is Type Narrowing?

Type narrowing is just what it sounds like—narrowing down a general type into something more precise. If you've ever dealt with union types, e.g. string | number you've certainly encountered this. In fact, optional types such as x?: number often require narrowing as well, as this typing is equivalent to x: number | undefined. In both of these cases, you'll likely need to handle each case in your code, and for that you'll need to narrow down the type first.

Ways to Narrow These Types

To narrow a union type down to one, we'll need to consider each case. We can do this with good old-fashioned control flow in JavaScript, as the TypeScript compiler is clever enough to infer the narrowing from our conditional logic. Typically, this just means using if or switch statements.

Let's consider a common, real-world example that I'm sure you've all written once or twice: a function that returns the deliciousness score for a given type of candy.

type Candy =
  | { name: "Skittles"; type: "Regular" | "Tropical" }
  | { name: "Black Licorice"; qty: number }
  | { name: "Runts"; isBanana: boolean };

function rateCandy(candy: Candy): number {
  switch (candy.name) {
    case "Skittles":
      return candy.type === "Regular" ? 8 : 7;
    case "Black Licorice":
      return candy.qty * -1;
    case "Runts":
      return candy.isBanana ? 11 : 5;
    default:
      throw new Error(`"${candy}" is not a valid candy!`);
  }
}

Because these candies each share a common field (name) we can use that to narrow down on a particular type of candy and use unique fields such as type and isBanana without confusing TypeScript.

Naturally, we could write this with if statements as well, but you get the idea. And since our conditional logic is exhaustive, TypeScript actually infers a never type for candy in our default case, which means that error will never throw (unless we go out of our way to trick the compiler).

Using typeof

Let's imagine we have a double function that accepts a string or number param. When given a string, we repeat it, and when given a number we multiply it by two. For this, we can use the typeof operator to narrow down our input and handle each case in a way that TS can understand.

function double(x: string | number) {
  if (typeof x === 'string') {
    return x.repeat(2);
  } else {
    return x * 2;
  }
}

So now double(5) returns 10, double('Pop!') returns Pop!Pop!, and TypeScript is perfectly happy.

The in and instanceof Operators

Let's say we have a function to get the total length of a movie or series.

type Movie = {
  title: string;
  releaseDate: Date | string;
  runtime: number;
}

type Show = {
  name: string;
  episodes: {
    releaseDate: Date | string;
    title: string;
    runtime: number;
  }[];
}

function getDuration(media: Movie | Show) {
  if ('runtime' in media) {
    return media.runtime;
  } else {
    return media.episodes.reduce((sum, { runtime }) => sum + runtime, 0);
  }
}

This works because we're able to check for a top-level field that's unique to Movie with the in operator, and handle the only other possible case (a Show type) separately.

But what if we want to get the year in which a show or movie premiered? We can use getFullYear on a date object, but if it's a date string we'll have to convert it to a Date first. Luckily, TypeScript lets us narrow this down safely using the instanceof operator.

function getPremiereYear(media: Movie | Show) {
  const releaseDate =
    "releaseDate" in media ? media.releaseDate : media.episodes[0].releaseDate;

  if (releaseDate instanceof Date) {
    return releaseDate.getFullYear();
  } else {
    return new Date(releaseDate).getFullYear();
  }
}

If you're not familiar with instanceof, it simply evaluates to a boolean representing whether the left-hand side of the expression is an instance of the object on the right-hand side. You can use instanceof to check instances of custom classes as well.

Type Predicates

Now for a more advanced case that you may very well have run into already. If we were to ask our users for their favorite foods, but made that information optional, we could end up with some data like this:

const favoriteFoods = [
  'Pizza',
  null,
  'Cheeseburger',
  'Wings',
  null,
  'Salad?',
];

We can filter out the null values with something like this:

const validFavoriteFoods = favoriteFoods.filter(food => food != null); // "!=" will catch undefined as well

// or if we want to exclude any "falsey" values

const validFavoriteFoods = favoriteFoods.filter(Boolean);

Unfortunately, while that does manage to filter out the null values, TypeScript isn't smart enough to know for sure and will still infer a (string | null)[] type for validFavoriteFoods...

In cases like these, we can leverage a custom type guard, which is basically a function that returns a boolean determining whether a param is a certain type. We do this by using what's called a "type predicate" as that function's return type.

function isValidFood(food: string | null): food is string {
  return food !== null;
}

This handy type guard lets us safely handle null values, e.g.

for (const food of favoriteFoods) {
  if (isValidFood(food)) {
    console.log(food.toUpperCase());
  }
}

No runtime errors OR compiler errors now—life is pretty good! And for common patterns like this, we can get even fancier with a combination of TS utility types, generics, and type predicates:

const isNotNullish = <T>(value: T): value is NonNullable<T> => value != null;

Now if we use that as our filter, we'll get the types we were expecting originally.

const validFavoriteFoods = favoriteFoods.filter(isNotNullish); // string[]

A Quick Note on Type Assertions

Type assertions are commonly used to say “trust me, bro” to the compiler. If you’re not familiar with type assertions, they typically look like this:

const user = {} as User;

or (if you’re not using tsx and prefer angle bracket syntax):

const user = <User>{};

While this may feel unavoidable at times, the patterns outlined above are typically a better alternative, as they won’t weaken your application’s type safety. In addition to narrowing techniques, type annotation may be a safer alternative as well, e.g.:

const user: User = res.data;

Note though that if the data you’re annotating has an any type, you’re only giving yourself the illusion of type safety, even with the type annotation approach.

This problem often arises when dealing with api data. If you have full confidence that the api will adhere to the data contract and won’t change unexpectedly, this can be an acceptable use case for type assertion.

However, if you’re using an api that’s actively changing, not entirely reliable, or you’re just a little paranoid, there are several libraries that can help. Tools like Zod and io-ts alleviate these issues with runtime schema validation so you don’t end up debugging downstream issues in your application code when an api returns something unexpected.

Conclusion

I hope this post helps you understand type narrowing in TypeScript a little bit better. Thankfully we have a lot of options to choose from when dealing with union types, and even some advanced patterns we can reach for to keep our checks DRY. For more information on type narrowing, the official documentation is an excellent resource.

Photo credit: Nika Benedictova

Related Posts

TypeScript and the Second Coming of Node

Long before Node.js famously entered the backend development scene, the tech industry had experienced several evolutions of the "Next Big Thing" with ...

Formidable Icon

Jason Wilson

Read More

Check out more of Trey's blog posts