Narrowing and discriminated unions
A check narrows a union to the members that pass; a literal tag makes each member exact; guards are trusted; callbacks keep unchanged variables.
Predict first
Before you read: predict
ExamHall proctor console, compiled with tsc under strict: true. Three teams typed a candidate's attempt for the status tiles in three ways:
Which lines does tsc report?
type AttemptRow = { status: "running" | "submitted"; endsAt?: string; score?: number };type LooseState = { status: string; endsAt: string } | { status: string; score: number };type AttemptState = { status: "running"; endsAt: string } | { status: "submitted"; score: number }; const a: LooseState = { status: "running", endsAt: "10:40", score: 71 }; // line 5const b: AttemptState = { status: "running", endsAt: "10:40", score: 71 }; // line 6 export function show(x: AttemptRow, y: AttemptState) { if (x.status === "submitted") x.score.toFixed(0); // line 9 if (y.status === "submitted") y.score.toFixed(0); // line 10}The 30-second version
A check on a union narrows it: after typeof, in, instanceof, === or a truthiness test, each branch sees only the members that fit. A guard x is T is believed, never verified. When every member has its own literal tag, each carries only its own fields. A callback keeps a narrowing only of a variable never assigned afterwards.
A check narrows a union to the members that pass; a literal tag makes each member exact; guards are trusted; callbacks keep unchanged variables.
How it works
Step 1 of 12
typeof x === "string", x instanceof Date, "id" in x, x === "done" or if (x), the branch sees only the members that pass.In the kitchen
- until she reads the form's type code, the clerk may only read the boxes every form prints (step 1) → a union allows only what every member has, until a check narrows it
Analogy: A customs declaration office
Parcels arrive with a declaration form, and a clerk decides what may be read from it.
| At the customs office | In TypeScript |
|---|---|
| until she reads the form's type code, the clerk may only read the boxes every form prints (step 1) | a union allows only what every member has, until a check narrows it |
| each form type, gift or commercial, prints only its own boxes: a gift form has no invoice box (steps 8–9) | each member of a discriminated union carries only its own fields |
| the old all-in-one form makes every box optional; ticking "commercial" does not fill in the invoice box (step 10) | optional fields stay optional after a status check |
| a broker's stamp "inspected: commercial" is accepted without opening the crate, right or wrong (step 6) | a guard x is T is trusted |
| a colleague can rely on a "checked" note only while nobody can swap the form in the tray afterwards (steps 11–12) | a callback keeps a narrowing only for a variable that is not assigned again |
In real terms: narrowing is the compiler following your checks through the code and removing the union members they rule out, with no check of its own at run time.
Breaks down when:
- A clerk can open the crate. TypeScript checks nothing at run time: a wrong guard, or a wrong payload, goes straight through (
ts-runtime-boundary-validation). Wrong inference: "a narrowed value has been verified." - A form is paper that cannot change. A variable can be assigned again, which is why a callback may lose the narrowing (step 12).
Contrasting case: a clerk handed an unlabelled crate with no form at all. Nothing can be read until it is identified, which is unknown (ts-unknown-any-never).
Examples, run for real
4.1 ExamHall: the proctor console live feed
EXH-1540: a locked candidate's tile on the proctor console showed a countdown. The tile read an AttemptRow whose endsAt, score and reason were all optional, and the feed message for the locked attempt still carried its old endsAt. MR !1740 types the attempt as AttemptState, one member per status, and switches on status.
type AttemptState =
| { status: "idle" }
| { status: "running"; endsAt: string }
| { status: "submitted"; score: number }
| { status: "locked"; reason: string };
function tileText(a: AttemptState): string {
switch (a.status) {
case "running": return `ends ${a.endsAt}`;
case "submitted": return `score ${a.score}`;
case "locked": return `locked: ${a.reason}`;
default: return "waiting";
}
}
4.1 AttemptRow, locked tile | tsc: no diagnostics | node prints: ends 10:40
4.1 AttemptState, locked tile | tsc: no diagnostics | node prints: locked: camera off
4.1 running case reads score | tsc: TS2339 at ex1-wrong-field.ts:9
4.1 locked literal + endsAt | tsc: TS2353 at ex1-impossible.ts:16
The row type accepted a locked attempt with a deadline, and the tile trusted the first field it found (step 10). With the union, each case sees exactly one member (step 8). The running case cannot read score, and a locked literal cannot carry endsAt (step 9).
4.2 Proof domain, catalog: a product card
A product card formats a price that arrives as a number or a string, and a stock count that may be missing.
function priceLabel(price: number | string): string {
if (typeof price === "string") return price.trim();
return price.toFixed(2);
}
function stockLabel(stock: number | undefined): string {
if (stock) return `${stock} left`;
return "ask in store";
}
4.2 price 24, stock 0 | tsc: no diagnostics | node prints: 24.00 | ask in store
typeof sends a string to the first branch and leaves number for the rest (steps 1–2). The truthiness test compiles too, yet a stock of 0 reads "ask in store" (step 4).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A category page formats four facets. strict: true.
Which lines does tsc report?
type Range = { min: number; max: number }; export function rangeText(r: Range | null): string { if (typeof r === "object") return `${r.min}-${r.max}`; // line 4 return "any price";}export function updatedText(at: Date | string): string { if (at instanceof Date) return at.toISOString(); // line 8 return at.toUpperCase(); // line 9}export function stockText(qty: number | null | undefined): string { if (qty !== null) return `${qty.toFixed(0)} left`; // line 12 return "ask in store";}export function brandText(brand: string | undefined): string { if (brand) return brand.toUpperCase(); // line 16 return "no brand";}Question 2 of 5
A chat client labels each message; a photo without a caption is meant to fall through to its link. strict: true; the file is built with tsc, and if the build passes, the built script is run.
What happens?
type Text = { kind: "text"; body: string };type Photo = { kind: "photo"; url: string; caption: string };type Msg = Text | Photo; function isPhoto(m: Msg): m is Photo { return m.kind === "photo" && m.caption !== "";} function label(m: Msg): string { if (isPhoto(m)) return `photo: ${m.caption}`; // line 10 if (m.kind === "photo") return `photo: ${m.url}`; // line 11 return `text: ${m.body}`;} console.log(label({ kind: "photo", url: "https://img.test/cat.png", caption: "" }));Question 3 of 5
The proctor console flashes a tile's alert a moment after it arrives. strict: true.
Which lines does tsc report?
interface Tile { seat: string; alert?: string }declare function later(run: () => void): void;declare function show(text: string): void; export function flash(tile: Tile, note: string | undefined) { if (tile.alert === undefined || note === undefined) return; later(() => show(note)); // line 7 later(() => show(tile.alert)); // line 8 later(announce); function announce() { show(note); } // line 10}Question 4 of 5
A box office holds every chosen seat with a promo code, then logs the retry code. strict: true; tsc reports line 8.
Which single change makes tsc report nothing, with the function doing the same work?
interface Seat { id: string }declare function hold(seat: Seat, code: string): void;declare function log(code: string | undefined): void; export function holdAll(seats: Seat[], promo: string | undefined, retry: string | undefined) { let code = promo; if (code !== undefined) { seats.forEach((s) => hold(s, code)); // line 8 } code = retry; // line 10 log(code);}Question 5 of 5
A checkout page labels the saved payment method. strict: true.
Which check, written in place of /* check */, lets the file compile?
interface Card { pan: string; expiry: string }interface Wallet { walletId: string }type Method = Card | Wallet; export function label(m: Method | undefined): string { if (m === undefined) return "no saved method"; if (/* check */) return `card ${m.expiry}`; // line 7 return `wallet ${m.walletId}`; // line 8}Go deeper
Pro tips
- Let
filterinfer the guard: write(s) => s !== undefinedwith no return type. A: booleanannotation turns the inference off, and the array keeps itsundefined(step 7). When: removing gaps from a list of optional values. Verify:tip 1 filter callbacks | tsc: TS2322 at tip1.ts:4(line 3, unannotated, compiles). Status: RAN. - Test numbers and strings with
!== undefined, not truthiness, so0and""still take the branch. When: a count, a price or a text field that may be missing. Status: RAN.
Source
- TypeScript Handbook, "Narrowing": https://www.typescriptlang.org/docs/handbook/2/narrowing.html · fetched 2026-09-30. Quoted: "in JavaScript,
typeof nullis actually"object"!"; "The "true" branch narrowsx's types which have either an optional or required propertyvalue"; "Any timeisFishis called with some variable, TypeScript will narrow that variable to that specific type if the original type is compatible."; "When every type in a union contains a common property with literal types, TypeScript considers that to be a discriminated union"; and, of the optional-fieldShape, "the type-checker doesn't have any way to know whether or notradiusorsideLengthare present based on thekindproperty." Supports steps 1–6 and 8–10. Valid as of 2026-09-30. - TypeScript 5.4 release notes, "Preserved Narrowing in Closures Following Last Assignments": https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-4.html · fetched 2026-09-30. Quoted: "When parameters and
letvariables are used in non-hoisted functions, the type-checker will look for a last assignment point." Valid as of 2026-09-30. - TypeScript 5.5 release notes, "Inferred Type Predicates": https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-5.html · fetched 2026-09-30. Quoted: "The function does not have an explicit return type or type predicate annotation." Supports step 7 and pro tip 1. Valid as of 2026-09-30.
Related lessons
Checked against the tools and docs as of 2026-09-30.