AcademyTypeScript
Narrowing: how a union becomes one type
The checks TypeScript understands — typeof, instanceof, in, truthiness, discriminants — and what makes a narrowing silently fall apart again.
3 min readUpdated 3 exercises
The compiler follows your control flow
A union is a value that is one of several things. Narrowing is how you get from the union to the one thing, and TypeScript does it by reading the same checks you would write anyway:
function print(value: string | number) {
if (typeof value === 'string') {
value.toUpperCase(); // string here
} else {
value.toFixed(2); // number here
}
}Nothing was annotated. The typeof check is also a type-level statement, and the compiler tracks it through the branches. Five checks do this work:
| Check | Narrows |
|---|---|
typeof x === 'string' | primitives |
x instanceof Error | classes |
'user' in payload | by property presence |
if (x) | removes null, undefined, '', 0, false |
x.kind === 'circle' | a discriminated union |
Truthiness narrows more than you asked for
if (value) is the one that bites. It removes null and undefined — and also the empty string and zero:
function greet(name: string | null) {
if (!name) return 'Hello, stranger'; // also catches ''
return 'Hello, ' + name;
}
function label(count: number | undefined) {
if (!count) return 'none'; // 0 is "none" now. Probably not what you meant.
return String(count);
}When the union is with null or undefined and the other member can be falsy, compare explicitly: if (count === undefined). The type is the same either way; the behaviour is not.
Discriminated unions are the ones worth designing
Anything narrows, but a union with a shared literal field narrows perfectly — and the compiler can then tell you when you missed a case:
type Result =
| { status: 'ok'; data: string }
| { status: 'error'; message: string };
function render(result: Result) {
if (result.status === 'ok') {
return result.data; // 'error' branch has no data — and it is gone here
}
return result.message;
}The field can be any literal type — a string, a boolean, a number. What makes it work is that the field exists on every member with a different literal value, so one comparison eliminates all the others. Design your states this way and most of your undefined checks disappear, because the impossible combinations are no longer representable.
When the check is not something the compiler can read
Sometimes the test is a function, and the compiler has no way to know that a true return means something about the argument. A type predicate says so:
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'email' in value;
}
if (isUser(payload)) {
payload.email; // User
}value is User is a promise the compiler cannot verify — the body could return true for anything. That is the trade: you get narrowing across a function call, and you own the correctness of that one function. Keep them small enough to read in one go.
What un-narrows a value
A narrowing is tied to a place in the control flow, and three things throw it away:
- A function call in between. The compiler assumes any call could have reassigned a mutable variable it closes over.
- `let` where `const` would do. A
constnarrows once and stays narrowed; aletis re-widened after anything that could write to it. - Reading through an index or a getter.
obj.items[0]is a fresh read each time — narrow it into a local first.
if (state.user) {
await save();
state.user.email; // Error: 'state.user' is possibly 'null'
}
const user = state.user; // narrow into a local
if (user) {
await save();
user.email; // fine
}That error is not the compiler being pedantic. Between those two lines, anything could have set state.user to null — and the version that copies into a local is also the version that does not read a moving target twice.
Now practice it
2 challenges · 1 quizReading this page is the cheap half. These are the exercises that make you use it.
- Narrowing — typeof, instanceof & inRuns through typeof, instanceof and in on a union until the branches stop needing annotations.ChallengeSolve · EasyChallengeSolve · Easy
- Type Guards (x is T)Writing the x is T predicate, which is the part of narrowing the compiler trusts you about.ChallengeSolve · EasyChallengeSolve · Easy
- Advanced Types · Union & Intersection TypesUnion and intersection questions, where most wrong answers come from assuming a narrowing survives.QuizTake · EasyQuizTake · Easy
Read next
Where any, unknown and never belong
The three types that are not about your data: one switches the compiler off, one makes you prove things, and one means this cannot happen.
3 min
Equality: ==, === and Object.is
What loose equality really does, the two cases where === is not what you want, and why comparing objects almost never means what people expect.
3 min