TSer/03 · Unions and narrowing
CHAPTER 03 · typeof, in, discriminated unions

Check it first, then use it

A value that might be a string or a number is not the problem. Using it as a string without checking is. Every check you write removes one possibility, and the compiler follows along line by line.

§01

Unions: this value could be one of several types

The vertical bar reads as "or". A value of type string | number is either a string or a number. First see how it is written, then see the problem it creates.

union.ts
1type Size = "small" | "medium" | "large"; // literal union: one of three
2let id: string | number; // either a string or a number
3
4id = "A-042"; // ✓
5id = 42; // ✓
6id = true; // ✕ Type 'boolean' is not assignable to type 'string | number'.

A union says what the value might be. It does not say what the value is. So the compiler has to assume the worst case, and it only allows the members that every type in the union has.

Only shared members
1function printId(id: string | number) {
2 id.toString(); // ✓ string has it, number has it too
3 id.toUpperCase(); // ✕ Property 'toUpperCase' does not exist
4 // on type 'string | number'.
5 // Property 'toUpperCase' does not exist
6 // on type 'number'.
7}
Read the error from the bottom up. The second line names the member that is missing: number has no toUpperCase. Because one member of the union lacks it, the union as a whole lacks it. toString is fine, because both types have it.

To use a member that only one type has, write a check first. The check tells the compiler which type you are on.

After the check
1function printId(id: string | number) {
2 if (typeof id === "string") {
3 return id.toUpperCase(); // inside this branch id is string
4 }
5 return id.toFixed(0); // only number can reach this line
6}

| means "or", not "and"

string | number means the value is a string or a number, one of the two. It does not mean the value has the abilities of both. That would be an intersection type, &, which chapter 02 covered. So adding more members to a union makes it less usable without a check: more members means fewer shared members.

§02

Inside a branch, the type gets more specific

When you write a condition, the compiler reads it and gives the variable a more specific type inside that branch than the type it was declared with. This animation plays the whole process once. Watch the list of possibilities shrink.

One union, one check at a time
Coming inx: string | number | boolean | null
if (x === null) return "nothing here"…not checked yet
if (typeof x === "string") return "text: " + x…not checked yet
if (typeof x === "number") return x.toFixed(2)…not checked yet
x at this point
stringnumberbooleannull
x: string | number | boolean | null
A value arrives with the type x: string | number | boolean | null. At this point the compiler has to treat all four as possible, so you may only use the members that all four have.
1 / 6

This process has a name: narrowing

Making a type more specific inside a branch is called narrowing. The compiler does it by walking through your code in execution order and remembering, at each line, which possibilities are still open. That walk is called control flow analysis. The same variable can have a different type on line 3 and on line 5. In your editor, hover over the same variable on different lines and you will see the type change.

§03

The checks that narrow

Only some expressions narrow, and each one fits a different kind of union. Click through them to see the code, what the compiler concludes, and the mistake that goes with it.

Check 1 · typeof
1function fmt(x: string | number) {
2 if (typeof x === "string") {
3 return x.toUpperCase(); // on this path x is string
4 }
5 return x.toFixed(2); // only number reaches this line
6}
What the compiler concludes

When typeof x === "string" is true, the compiler removes everything that is not a string; the else branch removes the string. typeof returns exactly eight strings: "string", "number", "bigint", "boolean", "symbol", "undefined", "object" and "function". It is the first choice for primitive types.

Common mistake

typeof null === "object". This has been true since the first version of JavaScript in 1995 and cannot be changed without breaking existing code. So typeof x === "object" does not exclude null. Write x !== null first.

Two mistakes worth memorising

1. A truthiness check also removes 0 and the empty string. if (count) looks like it removes only undefined, but 0 is falsy too, so it goes to the else branch. Zero toppings is real data, not a missing value. Write count !== undefined when that is what you mean.

2. typeof does not catch null. typeof null returns "object". So typeof x === "object" is true for null as well. Check x !== null first.

§04

Where narrowing is lost

Narrowing is not permanent. It holds along one path through the code, and there are places where the compiler has to give it up. These are the errors that confuse people the most, so it is worth seeing them now.

A callback is the common case. The compiler cannot know when the callback will run. It may run after the variable has changed, so the check you wrote earlier no longer proves anything.

A callback does not inherit the narrowing
1declare function later(fn: () => void): void;
2
3function schedule(x: string | number) {
4 let value = x;
5
6 if (typeof value === "string") {
7 value.toUpperCase(); // ✓ here value is string
8
9 later(() => {
10 value.toUpperCase();
11 // ✕ Property 'toUpperCase' does not exist on type 'string | number'.
12 // Property 'toUpperCase' does not exist on type 'number'.
13 });
14 }
15
16 value = 42; // this assignment is why the callback cannot trust the check
17}
Remove the last line and the error disappears: if value is never reassigned anywhere in the function, the compiler treats it as fixed and keeps the narrowing inside the callback. The rule is about whether the variable can change, not about callbacks as such.

A narrowed property behaves the same way, and it is stricter: an object property can always be reassigned from elsewhere, so the compiler never carries property narrowing into a callback.

✕ Property narrowing does not cross into the callback
1type Draft = { note?: string };
2
3function save(d: Draft) {
4 if (d.note) {
5 d.note.trim(); // ✓ here d.note is string
6
7 later(() => {
8 d.note.trim(); // ✕ 'd.note' is possibly 'undefined'.
9 });
10 }
11}
✓ Copy the value into a const first
1function save(d: Draft) {
2 const note = d.note; // copy the value into a const
3 if (note) {
4 later(() => {
5 note.trim(); // ✓ a const cannot change, so the check still holds
6 });
7 }
8}
This is the standard fix, and it is honest: you are capturing the value you checked, instead of hoping the property still holds it later.

One place where the compiler trusts you too much

Inside the same function, a narrowed property survives an ordinary function call. If you write if (d.note) { clear(d); d.note.trim(); }, the compiler reports nothing, even though clear may have set d.note to undefined. TypeScript accepts this gap on purpose: tracking every possible mutation would reject far too much correct code. It is one of the few places where a clean compile does not mean the value is still there.

Three rules to remember

Assigning to a let resets its narrowing from that line on. A callback keeps the narrowing of a variable only if that variable is never reassigned. A narrowed property is never kept inside a callback. When you hit any of the three, the fix is almost always the same: copy the checked value into a const.

§05

Discriminated unions: one field decides the whole shape

An order has three states, and each state carries different fields. How do you make the compiler know, inside each branch, which fields are present?

Start with the version that does not work well. All the fields are packed into one type, and the rule connecting a status to its fields exists only in your head.

✕ One wide type with optional fields
1// One wide type with optional fields. You have to remember the rules.
2interface LooseOrder {
3 status: string; // any string; a typo still compiles
4 paidAt?: Date; // when is it present? the type does not say
5 deliveredAt?: Date; // same problem
6}
7
8function report(o: LooseOrder) {
9 if (o.status === "paid") {
10 // paidAt is still Date | undefined here — the compiler
11 // does not know that "paid" implies paidAt.
12 return o.paidAt!.toLocaleTimeString(); // ! is the only way through
13 }
14}
That ! is where the type system stopped helping. And because status is a plain string, a typo like "pald" compiles without a word.
✓ Discriminated union
1// One member per state. The status field is the tag.
2type Order =
3 | { status: "pending"; createdAt: Date }
4 | { status: "paid"; createdAt: Date; paidAt: Date }
5 | { status: "delivered"; createdAt: Date;
6 paidAt: Date; deliveredAt: Date };
7
8function report(order: Order) {
9 if (order.status === "paid") {
10 return order.paidAt.toLocaleTimeString(); // ✓ no !, paidAt is always there
11 }
12}
One member per state, and the type of status is a literal type — the word "paid" itself, not string. Comparing that one field fixes the shape of the whole object.

With switch, the compiler knows the exact shape of order inside every case.

Sorting by status
1function report(order: Order): string {
2 switch (order.status) {
3 case "pending":
4 return "Preparing your order";
5 case "paid":
6 return "Paid at " + order.paidAt.toLocaleTimeString();
7 // ^ in this branch the compiler knows paidAt exists
8 case "delivered":
9 return "Delivered at " + order.deliveredAt.toLocaleTimeString();
10 // ^ here paidAt and deliveredAt both exist
11 }
12}
Pick a status and see what the compiler knows in that branch
switch (order.status) {}
Type of order in this branch{ status: "paid"; createdAt: Date; paidAt: Date; }
status ✓createdAt paidAt deliveredAt
order.deliveredAt → Property 'deliveredAt' does not exist on type '{ status: "paid"; createdAt: Date; paidAt: Date; }'.

One comparison, status === "paid", turns paidAt from "may be missing" into "always there". You did not write an extra if and you did not need !.

What makes a union discriminated

Three conditions. First, every member has the same field — the name can be status, kind, type, anything. Second, that field has a literal type, such as "paid" or 1 or true, not a wide type like string. Third, the literals are different in every member. Meet all three and one comparison narrows the whole object. This is the normal way to write a state machine in TypeScript.

§06

never and exhaustiveness checking

never is the type with no possible values — the empty union. That sounds useless, and it turns into one of the most useful techniques in the language.

Follow the narrowing to its end. Each case removes one member from the union. After the last member is removed, nothing is left. The type of that nothing is never. And never accepts no value at all, so an assignment to a never variable only compiles when the compiler agrees that the line is unreachable.

Exhaustiveness check
1function report(order: Order): string {
2 switch (order.status) {
3 case "pending": return "Preparing";
4 case "paid": return "Paid";
5 case "delivered": return "Delivered";
6 default: {
7 // All three states were handled above, so the type that is
8 // left over here is the empty union: never.
9 const _exhaustive: never = order;
10 return _exhaustive;
11 }
12 }
13}
Read the assignment as a claim: "by the time we reach default, order has no possibilities left." Right now the claim is true, so the code compiles and nothing happens.

The value shows up later. Three months on, the product needs refunds, so you add a fourth state to the type — and you do not touch the function.

The moment a state is added
1type Order =
2 | { status: "pending"; createdAt: Date }
3 | { status: "paid"; createdAt: Date; paidAt: Date }
4 | { status: "delivered"; createdAt: Date;
5 paidAt: Date; deliveredAt: Date }
6 | { status: "refunded"; refundedAt: Date }; // ← the new state
7
8// The moment you save the file, the default branch of report reports:
9// Type '{ status: "refunded"; refundedAt: Date; }' is
10// not assignable to type 'never'.
11// In plain words: one state is not handled yet. Add the missing case.

The compiler keeps the list of places to update

If twenty functions switch on order.status, adding one state makes all twenty report an error and name themselves. You work down the error list and cannot miss one. Without the check, the new state would fall silently into default, or into no branch at all, and the function would return undefined.

Without the check there is no protection

The protection comes from the never assignment, not from having a default branch. A default that just returns "unknown status" tells the compiler you handled it, and the new state passes without a word.

§07

Type predicates and assertion functions

You can move a check into its own function, but a function that returns plain boolean does not narrow anything at the call site. You have to say what the true result means.

type-predicate.ts
1type Paid = { status: "paid"; createdAt: Date; paidAt: Date };
2
3// The return type is not boolean but `o is Paid`. It means:
4// "if I return true, the caller may treat o as Paid".
5function isPaid(o: Order): o is Paid {
6 return o.status === "paid";
7}
8
9declare const orders: Order[];
10const paidOrders = orders.filter(isPaid);
11// paidOrders: Paid[] — the element type follows the filter.
12// With a plain `boolean` return type the result would stay Order[].

The compiler does not check a predicate

o is Paid is a promise you make to the compiler, and the compiler takes it without reading the function body. Write return o.status === "pending" by mistake and it is believed. From then on the types no longer describe the program. This is one of the few ways to make TypeScript wrong on purpose, so keep predicate bodies short and obvious.

A predicate narrows inside an if. An assertion function narrows everything after the call instead: it throws when the check fails, so if execution continues, the check passed.

assert.ts · narrowing after the call
1// An assertion function throws when the check fails.
2// Everything after the call is narrowed.
3function assertPaid(o: Order): asserts o is Paid {
4 if (o.status !== "paid") throw new Error("order is not paid");
5}
6
7function receipt(o: Order) {
8 assertPaid(o);
9 return o.paidAt.toLocaleTimeString(); // o is Paid from here on
10}
11
12// If you store it in a variable, the variable needs a type annotation:
13const check: (o: Order) => asserts o is Paid = assertPaid;
14// Without that annotation the call site reports:
15// ✕ Assertions require every name in the call target to be
16// declared with an explicit type annotation.
The annotation rule is easy to hit by accident. A function declaration is fine as written. If the assertion is held in a variable, that variable needs an explicit type, otherwise the call does not narrow and the compiler reports it.

Since TypeScript 5.5 you often do not need to write the predicate yourself.

TS 5.5: predicates inferred from the body
1const names = ["jasmine", undefined, "oolong"]
2 .filter((n) => n !== undefined);
3
4// Up to TS 5.4: names is (string | undefined)[] — the filter did not help.
5// From TS 5.5: names is string[].
6// For a short filter callback the compiler infers the predicate for you.
The compiler infers a predicate only for a short callback with one parameter that immediately returns a narrowing expression. Anything longer still needs an explicit is, and writing it explicitly means the promise is yours again.
§08

Working with null and undefined: ?., ?? and !

Under strict mode, null and undefined are separate possibilities that have to be narrowed away. These three operators deal with them in three different ways: skip, substitute, and override.

All three side by side
1interface Member {
2 nickname?: string;
3}
4
5function greet(m: Member | null) {
6 m.nickname; // ✕ 'm' is possibly 'null'.
7
8 const n1 = m?.nickname;
9 // ?. stops as soon as m is null or undefined, and the whole
10 // expression becomes undefined. n1: string | undefined
11
12 const n2 = m?.nickname ?? "Guest";
13 // ?? uses the right side only when the left side is null or
14 // undefined. n2: string
15
16 const n3 = m!.nickname;
17 // ! claims "m is not null". Nothing is checked at runtime;
18 // the compiler just stops reporting. n3: string | undefined
19}

?? has an older relative that looks similar, ||. The difference is exactly the truthiness problem from §03.

?? vs ||
1declare const order: { sugar?: number };
2
3const a = order.sugar ?? 50; // falls back only for null or undefined
4const b = order.sugar || 50; // falls back for 0 and "" as well
5
6// The customer asked for 0% sugar, so order.sugar is 0:
7// a === 0 the customer's choice is kept
8// b === 50 0 was treated as "not filled in"
|| tests truthiness, so 0, "" and NaN are replaced too. ?? tests only for null and undefined. For default values, use ??.

! is not a fourth kind of check

?. and ?? are real JavaScript operators. They compile to real runtime checks. ! is a TypeScript annotation and disappears when the code is compiled. It performs no check at all; it only stops the compiler from reporting. If the value really is null at runtime, you get the same TypeError you would have got in JavaScript, and now without the warning. Rule: if ?. or ?? can express what you mean, use them instead.

§09

Practice

You learn narrowing by watching the hover tooltip change from one line to the next. Five tasks, all of them fit in the TypeScript Playground.

§10

Quiz

Ten questions, from the shared-member rule to the real cost of !. Get them all right and the sidebar marker turns green.

QUESTION 01 / 10

function f(id: string | number) { id.toUpperCase(); } fails to compile. Why?

QUESTION 02 / 10

What does typeof null return?

QUESTION 03 / 10

count: number | undefined, and the code is if (count) { A } else { B }. Which branch runs when count is 0?

QUESTION 04 / 10

x is string | number. Inside if (typeof x === "string") you pass a callback to setTimeout that calls x.toUpperCase(), and somewhere else in the same function you also write x = 42. What happens?

QUESTION 05 / 10

What must be true of the tag field in a discriminated union?

QUESTION 06 / 10

Which of these are real narrowing — a check that actually runs, which the compiler uses to reduce the type? (Select all.)

QUESTION 07 / 10

Exhaustiveness check: writing const _x: ____ = order; in the default branch turns a forgotten case into a compile error. Which type goes in the blank?

QUESTION 08 / 10

function isPaid(o: Order): o is Paid — what does o is Paid tell the compiler to do?

QUESTION 09 / 10

What does the ! in m!.nickname (the non-null assertion) actually do?

QUESTION 10 / 10

const sugar = order.sugar || 50; — the customer asked for 0% sugar, so order.sugar is 0. What is sugar?

What to take away from this chapter
  • A union only lets you use the members that every type in it has, because the compiler assumes the worst case. To use the rest, narrow first.
  • Narrowing is control flow analysis: the compiler walks your code in order and gives a variable a more specific type inside each branch. typeof, truthiness, equality, in, instanceof, Array.isArray and a literal comparison all narrow.
  • Two mistakes to remember: if (x) also removes 0 and "", and typeof null === "object".
  • Narrowing is lost when a let is reassigned, and inside a callback for any variable that can be reassigned or any object property. Copy the checked value into a const.
  • A discriminated union needs a shared field, a literal type, and a different literal in every member. Then one comparison narrows the whole object.
  • const _x: never = order in the default branch is an exhaustiveness check. Once every case is handled, the remaining type is the empty union, so the assignment compiles — and it stops compiling the moment a new member appears.
  • o is Paid packages a check for reuse, and asserts o is Paid narrows after the call. The compiler never verifies either body — that promise is yours.
  • ?. and ?? compile to real runtime checks. ! compiles to nothing and checks nothing. Never use || for a default value that could legitimately be 0 or "".