Narrowing in TypeScript: Shrinking Unions with Control Flow Analysis
We see how TypeScript narrows unions through control flow analysis: typeof, truthiness, equality, in and instanceof checks, discriminated unions and exhaustive checks with never.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
In the previous post we narrowed a union once with typeof. Now let's push the same tool a little harder. Does this code compile? If it errors, on which line and why?
function printLength(value: string | string[] | null) {
if (typeof value === "object") {
console.log(value.length);
}
}
The logic looks solid: strings give "string", arrays give "object". So inside the if block we must have an array, right? We'll answer in the typeof section.
Narrowing and Control Flow Analysis
Narrowing means reducing a union to a narrower type at a particular point in the code. You don't have to teach TypeScript anything new for it; it reads the if, switch, return and assignments you already write in JavaScript. This is called control flow analysis: the compiler walks through your code like a flowchart and works out, at every point, which type a variable can have.
function formatPrice(price: number | string) {
// price: number | string
if (typeof price === "string") {
price = Number.parseFloat(price);
// price: number
}
// price: number
return price.toFixed(2);
}
console.log(formatPrice("19.5")); // 19.50
console.log(formatPrice(7)); // 7.00
At the start of the function, price can be either of the two types. Inside the if block TypeScript knows it's a string and lets you pass it to Number.parseFloat. Because you assign a new number in the block, price is a number from that line on. Below the block, the two paths join: either the block ran and the value was converted to a number, or the block never ran because the value was already a number. Both paths end in number, so toFixed can be called safely.
You can see this calculation on every line by hovering over the variable in your editor. That's also the best way to learn narrowing: write a check, then look at the variable's type right below it.
Narrowing with typeof
JavaScript's typeof operator returns eight different strings: "string", "number", "bigint", "boolean", "symbol", "undefined", "object" and "function". TypeScript recognizes each of them and picks the matching members from the union.
The answer to the opening question hides in an old JavaScript quirk: typeof null also returns "object". TypeScript knows this; inside the if block it computes the type of value as string[] | null and objects like this:
error TS18047: 'value' is possibly 'null'.
We forgot about null; the compiler didn't. The fix is to rule out null separately:
function printLength(value: string | string[] | null) {
if (value !== null && typeof value === "object") {
// value: string[]
console.log(value.length);
}
}
For arrays there's a more readable option too: Array.isArray(value). TypeScript recognizes this function as well and narrows the value to an array in the true branch.
Narrowing with Truthiness
You'll remember from the JavaScript series: a value inside an if is converted to a boolean, and false, 0, "", null, undefined and NaN count as falsy. TypeScript uses this rule for narrowing too:
function printLength(value: string | string[] | null) {
if (value) {
// value: string | string[]
console.log(value.length);
} else {
// value: string | null
console.log("nothing to measure");
}
}
In the if (value) block, null is gone, because null can never be truthy. Pay attention to the else branch: string[] isn't there, because arrays are always truthy, even when empty. But string is still there. The reason is that the empty string is falsy; the value reaching else could be null, but it could also be "". Truthiness is short and handy, but you always have to account for what it rules out. We'll come back to this in the pitfalls section.
Equality Checks
=== and !== narrow as well. Their most common use is ruling out a specific value, but sometimes they give more interesting results:
function compare(a: string | number, b: string | boolean) {
if (a === b) {
// a: string, b: string
return a.toUpperCase() === b.toUpperCase();
}
return false;
}
If a and b are equal, they must be of the same type. Since the only member the two unions share is string, TypeScript narrows both of them to string inside the block.
Loose equality, ==, has one useful application: x == null catches both null and undefined. TypeScript knows this too:
function greet(name?: string | null) {
if (name == null) {
return "Hello, stranger!";
}
// name: string
return `Hello, ${name}!`;
}
in and instanceof
With unions of objects, you can ask whether a property exists using the in operator:
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
// animal: Fish
animal.swim();
} else {
// animal: Bird
animal.fly();
}
}
If "swim" in animal is true, we have a Fish; otherwise a Bird. For values created from classes, you use instanceof:
function formatDate(value: Date | string) {
if (value instanceof Date) {
// value: Date
return value.toISOString();
}
// value: string
return value;
}
Here's the difference in a nutshell: in looks at the object's shape, while instanceof looks at which constructor it came from, that is, its prototype chain. Since most types in TypeScript are plain objects, you'll use in more often; instanceof is the right tool for Date, Error and your own classes.
Discriminated Unions
In the previous post we saw that we couldn't access shape.radius on the Circle | Square union. We could solve it with in, but there's a cleaner pattern: give every member a property with the same name whose value is a literal type.
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; size: number };
type Shape = Circle | Square;
function area(shape: Shape) {
switch (shape.kind) {
case "circle":
// shape: Circle
return Math.PI * shape.radius ** 2;
case "square":
// shape: Square
return shape.size ** 2;
}
}
console.log(area({ kind: "square", size: 3 })); // 9
The kind property exists on every member, but with a different literal on each. By looking at the value of shape.kind, TypeScript knows exactly which member it's working with. That shared property is called the discriminant, and the pattern itself is called a discriminated union. Common names are kind, type or status; what matters is that the same name exists on every member of the union and that the values differ from one another.
You'll run into this pattern everywhere in real TypeScript: the state of a request ("loading", "success", "error"), the steps of a form, the kinds of messages an application handles. Each carries its own data, and you look at each case separately with a switch.
Exhaustive Checks with never
Discriminated unions come with a seat belt, too. If someone adds a third member to Shape tomorrow, every switch needs updating. To make sure that isn't forgotten, you put a check in the default branch:
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; size: number };
type Triangle = { kind: "triangle"; base: number; height: number };
type Shape = Circle | Square | Triangle;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
default: {
// Error: Type 'Triangle' is not assignable to type 'never'.
const unhandled: never = shape;
return unhandled;
}
}
}
The never type we saw earlier is the type no value can have. If every member has been handled, the type of shape is never by the time you reach the default branch, and the assignment goes through. If a member was forgotten, shape carries that member's type and the assignment errors. On top of that, the message tells you what you forgot: Triangle. The moment you add a case "triangle" branch, the error goes away.
Writing the function's return type explicitly gives you similar protection. Without the default branch, TypeScript would instead say Function lacks ending return statement and return type does not include 'undefined'., telling you the function doesn't return a value on some paths. The never assignment is more descriptive, though, because it puts the error right on the forgotten case.
Common Pitfalls
The first pitfall is truthiness swallowing valid values too:
function formatCount(count: number | undefined) {
if (!count) {
return "no data";
}
return `${count} items`;
}
console.log(formatCount(0)); // no data
As far as type checking goes, everything is fine; TypeScript reports no error. But 0 is a valid number and doesn't mean "no data." If what you really mean is "no value," say so explicitly: if (count === undefined). The same trap applies to the empty string.
The second pitfall is that typeof x === "object" includes null. We saw this at the start of the post; wherever you check for "object", think about null as well.
The third pitfall is that narrowing doesn't always carry into callbacks:
function scheduleTitle(title: string | undefined) {
if (title !== undefined) {
// Error: 'title' is possibly 'undefined'.
setTimeout(() => console.log(title.toUpperCase()), 100);
}
title = undefined;
}
The callback runs later, and by then title may have changed; indeed, the last line assigns undefined to it. That's why TypeScript doesn't carry the narrowing from the if into the callback. Since TS 5.4 the compiler is smarter about this: if the variable is never assigned after the callback is created, the narrowing is preserved. So if you delete the last line, the error goes away too. The practical fix is not to reassign parameters and let variables later on, or to copy the narrowed value into a new const and use that in the callback.
A Small Experiment
★☆☆ In your browser console or in Node, evaluate typeof null, typeof [] and typeof (() => 1). Then open the first printLength example from this post in your editor and hover over value inside the if.
★★☆ What's the type? Guess first: in the compare function, if you write if (a !== b) instead of if (a === b), what's the type of a inside the if block? Does any narrowing happen?
★★☆ Add a case "triangle" branch to the example where we did the exhaustive check with never, and watch the error disappear. Then add a fourth shape of your choice to Shape and read what the error message says.
★★★ Write a discriminated union describing the state of a request: "loading", "success" (carrying a data: string[]) and "error" (carrying a message: string). Write a render function that returns a string for each state, using a switch and a never check. Then add an "idle" state to the union and see where the compiler sends you.
We wrote functions in every example in this post, but we barely touched the types of the functions themselves. In the next post we'll look at exactly that: optional, default and rest parameters, writing a function's type on its own, the subtlety of void returns, overloads and the this parameter.