Narrowing Types in TypeScript

April 13, 2022
Trey HooverTrey Hoover
Image of a table with a magnifying glass, a few compasses, and a few other miscellaneous items

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

Game of Types: A Song of GraphQL and TypeScript

May 23, 2019
Over the last few years, the popularity of both GraphQL and TypeScript has exploded in the web ecosystem—and for good reason: They help developers solve real problems encountered when building modern web applications. One of the primary benefits of both technologies is their strongly typed nature.
Steven Musumeche

TypeScript and the Second Coming of Node

September 25, 2018
Long before Node.js famously entered the backend development scene, the tech industry had experienced several evolutions of the "Next Big Thing" with both successful and failed patterns and techniques. In this article, we look at some of the shortcomings of Node.js programming models and how TypeScript offers the hope of increasing the legitimacy of Node.js ecosystem in the enterprise by bringing back some storied and successful programming paradigms from the past.
JW
Jason Wilson

Conversion to TypeScript: Lessons Learned from an OSS Maintainer

December 8, 2020
Formidable's Emil Hartz recently undertook the conversion of his long-maintained OSS package to TypeScript. We asked a few questions related to the conversion to shine light on any learnings for others considering the same undertaking.
Emil Hartz