Narrowing Types in TypeScript
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