Narrowing
The point of unions is narrowing
Section titled “The point of unions is narrowing”A union is only useful because you can narrow it. TypeScript reads your control flow — if, switch, typeof, return — and shrinks the type within each branch to the case that must be true there. This is control-flow analysis, and it’s one of the smartest parts of the compiler.
function printId(id: string | number) { if (typeof id === "string") { id.toUpperCase(); // ✅ here id is string } else { id.toFixed(2); // ✅ here id is number }}Inside the if, the type is string; the else branch removes string from the union, leaving number. You didn’t cast anything — the compiler narrowed based on a real runtime check it understands.
The built-in narrowing operators
Section titled “The built-in narrowing operators”TypeScript understands several ordinary JavaScript checks and narrows from them:
typeof x— narrows primitives:"string","number","boolean","object","function","undefined","symbol","bigint".instanceof— narrows to a class from its prototype chain."key" in obj— narrows object unions by presence of a property.- Truthiness —
if (x)removesnull,undefined,0,"",falsefrom the possibilities. - Equality —
if (x === "a")narrows to that literal; comparing two variables narrows both.
interface Circle { kind: "circle"; radius: number }interface Square { kind: "square"; side: number }
function area(shape: Circle | Square) { if ("radius" in shape) { return Math.PI * shape.radius ** 2; // shape is Circle } return shape.side ** 2; // shape is Square}in with optional properties
Section titled “in with optional properties”in narrows by which members of the union declare the property at all — not by whether the property is currently present at runtime. That means it works even when the property is optional, as long as only one side of the union declares it:
interface Cat { meow(): void }interface Dog { bark(): void; leash?: string }
function speak(pet: Cat | Dog) { if ("leash" in pet) { pet.bark(); // pet narrowed to Dog -- because only Dog even declares leash }}This narrows to Dog even for a Dog whose leash is currently undefined — in checks the type’s declared shape, and only Dog has a leash key at all, optional or not. If both members declared the same optional property, in alone couldn’t distinguish them and you’d need a real discriminant.
Equality narrowing between two variables
Section titled “Equality narrowing between two variables”Comparing two variables with === narrows both of them to whatever they have in common — not just one side to a literal:
function compare(a: string | number, b: string | boolean) { if (a === b) { // only string is common to both unions -- narrows both to string a.toUpperCase(); b.toUpperCase(); }}Neither a nor b is a literal here, so this isn’t the if (x === "a") case — the compiler reasons that a === b can only be true if both hold a value from the intersection of their possible types, string. Both variables narrow together inside the if, even though neither was compared against a fixed value.
Discriminated unions: the pattern to reach for
Section titled “Discriminated unions: the pattern to reach for”The cleanest way to model “one of several object shapes” is a discriminated union: every member shares a common literal field (the discriminant or tag), and switching on that field narrows to the right member.
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number } | { kind: "rect"; width: number; height: number };
function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.radius ** 2; // s is the circle member case "square": return s.side ** 2; case "rect": return s.width * s.height; }}flowchart TB u["Shape (union) circle | square | rect"] -->|switch on kind| c["kind === circle → radius available"] u --> sq["kind === square → side available"] u --> r["kind === rect → width, height available"]
This pattern is everywhere: Redux actions, API responses, state machines, result types. When you find yourself reaching for a class hierarchy to model variants, a discriminated union is usually simpler and safer.
Exhaustiveness with never
Section titled “Exhaustiveness with never”Here’s the payoff. Add a default branch that assigns the value to never, and the compiler will error the day someone adds a new union member and forgets to handle it:
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number } | { kind: "rect"; width: number; height: number };
function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.radius ** 2; case "square": return s.side ** 2; case "rect": return s.width * s.height; default: const _exhaustive: never = s; // compiles now — every member is handled return _exhaustive; // add a new Shape member and this line starts erroring, pointing at the gap }}In the default, every known case has been narrowed away, so s is never. Add a "triangle" member and s is no longer never there — the assignment fails to compile, pointing you straight at the missing case. This turns “did I handle every variant?” from a code-review question into a compile error.
Custom type guards: x is T
Section titled “Custom type guards: x is T”When narrowing needs custom logic, write a type-guard function whose return type is a type predicate x is T:
function isString(x: unknown): x is string { return typeof x === "string";}
function handle(v: unknown) { if (isString(v)) { v.toUpperCase(); // ✅ v narrowed to string by the guard }}The predicate is a promise to the compiler: “if this returns true, treat the argument as T.” It’s how you narrow unknown from external data, and how libraries expose validation that the type system trusts.
Type predicates can lie
Section titled “Type predicates can lie”That promise is never checked against the function’s body — the compiler trusts the signature, not the implementation. A type-guard function that lies compiles with zero errors:
function isString(x: unknown): x is string { return true; // lies -- always claims true, no real check}
function shout(x: unknown) { if (isString(x)) { console.log(x.toUpperCase()); // trusted -- x is "string" here }}// @runfunction isString(x: unknown): x is string { return true; // lies -- always claims true, no real check}
function shout(x: unknown) { if (isString(x)) { console.log(x.toUpperCase()); // trusted -- x is "string" here }}
shout(42); // not a string at allRunning it crashes exactly where the compiler said it was safe:
TypeError: x.toUpperCase is not a functionThis is the sharpest version of the course’s core warning: any type annotation — including a predicate — is a claim, not a proof. A hand-written or AI-generated type guard is only as trustworthy as its actual logic; always read the body of a custom x is T function before trusting what it narrows to.
Assertion functions: asserts x is T
Section titled “Assertion functions: asserts x is T”A cousin of the type guard is the assertion function, which throws instead of returning a boolean and narrows for the rest of the scope:
function assert(cond: unknown, msg: string): asserts cond { if (!cond) throw new Error(msg);}
function use(v: string | null) { assert(v !== null, "v must be set"); v.toUpperCase(); // ✅ v is string from here on — the assert removed null}After the call, the compiler knows the asserted condition held (or execution would have thrown), so it narrows accordingly. This is how assert-style helpers and Node’s assert give you type narrowing for free.
unknown in catch: narrowing an error before you can use it
Section titled “unknown in catch: narrowing an error before you can use it”Under strict (specifically useUnknownInCatchVariables, on by default since TypeScript 4.4 whenever strict is on), a caught exception is typed unknown, not any — because JavaScript lets you throw literally anything, so the compiler refuses to assume it has an Error’s shape:
// @expect-error TS18046try { JSON.parse("{");} catch (e) { console.log(e.message); // ❌ 'e' is of type 'unknown'.}Narrow it the same way you’d narrow any other unknown, most commonly with instanceof:
try { JSON.parse("{");} catch (e) { if (e instanceof Error) { console.log(e.message); // ✅ narrowed from unknown }}This is a direct, everyday application of the module’s whole theme: catch hands you the widest possible type on purpose, and narrowing is what turns it back into something you can safely call .message on.
Inferred type predicates (TypeScript 5.5)
Section titled “Inferred type predicates (TypeScript 5.5)”You no longer have to write x is T for the simplest guards. When a function returns a boolean that is about its parameter, TypeScript infers the predicate for you:
interface Bird { name: string }declare const countries: string[];declare const nationalBirds: Map<string, Bird>;
function isBirdReal(bird: Bird | undefined) { return bird !== undefined;}// inferred: (bird: Bird | undefined) => bird is Bird
const birds = countries .map((c) => nationalBirds.get(c)) .filter((b) => b !== undefined);// ^? Bird[] — was (Bird | undefined)[] before 5.5The compiler infers a predicate only when all four hold: the function has no explicit return-type annotation, it has a single return (and no implicit undefined return), the returned boolean is tied to a narrowing check on the parameter, and the parameter is never reassigned. Anything fancier — multiple returns, checks on a property of a mutable variable — still needs the explicit x is T.
Narrowing that got smarter
Section titled “Narrowing that got smarter”Three newer refinements remove common “why won’t it narrow?” moments:
-
switch (true)(5.3). Eachcaseexpression narrows as if it were anif:declare const x: unknown;switch (true) {case typeof x === "string": x.toUpperCase(); break;case x instanceof Date: x.getTime(); break;} -
Closures after the last assignment (5.4). A
const, or alet/parameter that is never reassigned afterwards, keeps its narrowed type inside callbacks created after the check:function getUrls(url: string | URL, names: string[]) {if (typeof url === "string") url = new URL(url);return names.map((n) => new URL(n, url)); // url: URL — no cast needed} -
Constant indexed access (5.5).
obj[key]narrows when bothobjandkeyare effectivelyconst:function f(obj: Record<string, unknown>, key: string) {if (typeof obj[key] === "string") obj[key].toUpperCase(); // ✅}
Checks the compiler now refuses (TypeScript 5.6)
Section titled “Checks the compiler now refuses (TypeScript 5.6)”The flip side of narrowing: a condition that can never narrow is almost always a bug, and 5.6 makes several of them errors. A bare regex or arrow function in an if is always truthy; a ?? whose left side can never be nullish is unreachable:
// @expect-error TS2872 TS2869if (/0x[0-9a-f]/) { } // ❌ this kind of expression is always truthyif ((x: number) => 0) { } // ❌ (you meant x >= 0) — an arrow function is always truthy too
function f(value: number, max: number) { return value < max ?? 100; // ❌ right operand of ?? is unreachable}Literal true, false, 0 and 1 stay allowed (while (true) is idiomatic). Regex literals are also syntax-checked since 5.5, so an unbalanced group or a backreference to a missing capture is a compile error, not a runtime surprise.
Narrowing limits: lost across a closure, optimistic across await
Section titled “Narrowing limits: lost across a closure, optimistic across await”Narrowing is a property of the linear control-flow graph the compiler builds for one function body — and that graph has real edges it can’t see across.
Lost across a closure. Narrowing an object’s property doesn’t survive being read inside a nested function, because that function might run at any later time, after something else has mutated the property:
// @expect-error TS2339interface State { id: string | number }function handleState(state: State) { if (typeof state.id === "string") { setTimeout(() => { state.id.toUpperCase(); // property narrowing does not survive a closure }, 0); }}The fix is to capture the narrowed value into a local const before creating the closure — a const can never be reassigned, so its narrowed type is safe to carry into a nested function:
interface State { id: string | number }function handleState(state: State) { if (typeof state.id === "string") { const id = state.id; // capture the narrowed value in a local const setTimeout(() => { id.toUpperCase(); // ✅ id is a const, never reassigned — narrowing survives }, 0); }}Optimistic across await. await doesn’t create a new function scope, so narrowing does survive it syntactically — but that’s actually unsound, because an awaited call can run arbitrary code that mutates the narrowed value while your function is suspended, and the compiler has no way to know:
// @runinterface State { id: string | number }
async function corrupt(state: State) { state.id = 42; // runs while the caller is suspended at its own await}
async function run(state: State) { if (typeof state.id === "string") { await corrupt(state); // TS has no idea this mutates state.id console.log(state.id.toUpperCase()); // still typed string here -- but is it? }}
run({ id: "abc" });This compiles with zero errors — TypeScript has no interprocedural analysis, so it doesn’t know corrupt touches state.id at all, and keeps trusting the narrowing from before the await. Running it crashes:
TypeError: state.id.toUpperCase is not a functionThe lesson generalizes: narrowing is only as reliable as the compiler’s certainty that nothing else ran in between. A closure might run later — narrowing loses on purpose, conservatively. An await runs now, in the same function, so narrowing optimistically survives — even though real code, including code you don’t control, gets a chance to invalidate it in between. Re-check (or re-narrow) any shared, mutable value immediately after an await, rather than trusting a check that happened before it.
Narrowing survives await, but it's optimistic
interface State { id: string | number }
async function corrupt(state: State) { state.id = 42; // runs while the caller is suspended at its own await}
async function run(state: State) { if (typeof state.id === "string") { await corrupt(state); // TS has no idea this mutates state.id console.log(state.id.toUpperCase()); // still typed string here -- but is it? }}
run({ id: "abc" }); Mental Model
Section titled “Mental Model”flowchart TD check["A runtime check the compiler understands (typeof, instanceof, in, equality, discriminant)"] --> cfa["Tracked in the linear control-flow graph of THIS function body"] cfa --> same["Used later in the SAME graph (if/else, switch, after an await)"] --> trusted["Narrowing holds"] cfa --> escapes["Read inside a NESTED function (closure, setTimeout, event handler)"] --> lost["Narrowing lost -- could run any time"] guard["A custom function returning x is T"] --> sig["Compiler trusts the SIGNATURE"] sig -.->|"never checks"| body["...not the function body"]
Every narrowing rule in this lesson is the compiler tracking one thing: a claim, and how far that claim can travel before something might invalidate it. A typeof check is a claim tracked precisely through the linear flow of one function. A type predicate is a claim the compiler takes on faith from a signature, never verified against the body. await doesn’t end the flow, so a claim survives it — optimistically, since real code ran in between. A closure begins a new flow that might execute at any time, so the compiler conservatively refuses to carry old claims into it. None of this is arbitrary: it’s exactly as much certainty as static analysis of one function body, read top to bottom, can actually offer.
Trade-offs
Section titled “Trade-offs”| Technique | Benefit | Cost |
|---|---|---|
Built-in operators (typeof, instanceof, in, equality) | Zero-cost, no custom code, compiler-verified against real checks | Only covers patterns the compiler recognizes natively |
Discriminated union + exhaustive switch | New variant is a compile error until handled everywhere | Requires every variant to carry a shared literal tag up front |
Custom type guard (x is T) | Encapsulates arbitrary validation logic behind a reusable check | The compiler trusts the signature, not the body — a wrong or incomplete guard compiles clean |
Assertion function (asserts x is T) | Narrows for the rest of the scope, not just one branch | Same trust problem as a type guard, plus it throws instead of branching |
Capturing into a const before a closure | Restores narrowing that a closure would otherwise lose | One extra local variable per narrowed value used in a callback |
Common Misconceptions
Section titled “Common Misconceptions”- A function returning
x is Tis verified by the compiler to actually check what it claims. It isn’t — the predicate is trusted from the signature alone. A guard that returnstrueunconditionally, or checks the wrong thing, compiles with zero errors. - Narrowing a variable before an
awaitis still safe after it. It’s syntactically preserved, but not actually safe — the awaited operation can run code that mutates the value while your function is suspended, and the compiler can’t see that. - Narrowing a variable before creating a closure survives inside that closure. It doesn’t, for property access — a closure might run at any later time, so the compiler drops the narrowing rather than risk it being stale. A
constlocal captured beforehand is the fix. inonly works when the property is actually present at runtime. It narrows based on which union member declares the property in its type, so it works even for a currently-undefinedoptional property, as long as only one member declares that key at all.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting generated narrowing logic, check for:
- A type-guard function (
x is T) whose body checks something much weaker than what it claims —typeof x === "object" && x !== nullreturningx is { id: string; name: string }is a common shape, and it compiles with zero errors while claiming far more than it verified. - A value narrowed before an
await, then used immediately after without re-checking — especially if anything awaited could plausibly touch the same shared object or module-level state. - A narrowed value read inside a
setTimeout, event handler, or any other callback defined inside the narrowing branch — if it’s a property access (not a captured local), the type is likely wider there than the surrounding code implies. - An assertion function or type guard used to validate data crossing a real boundary (parsed JSON, a fetch response) with a check that only confirms “is an object,” not the actual required shape.
🐛 Spot the bug
function isValidUser(x: unknown): x is { id: string; name: string } {return typeof x === "object" && x !== null;}
function greet(input: unknown) {if (isValidUser(input)) { console.log(`Hello, ${input.name}`);}}
greet({ id: "1" });Show the answer
This compiles with zero errors and does not crash — it just silently prints Hello, undefined. isValidUser claims the much stronger predicate x is { id: string; name: string }, but its body only checks “is a non-null object,” which { id: "1" } satisfies without ever having a name property. Because the compiler never checks a type guard’s body against its claimed predicate, greet trusts input.name is a string — it’s actually undefined, and template-literal interpolation happily stringifies that without throwing. The fix is to make the guard’s runtime check match its claim: typeof x === "object" && x !== null && "id" in x && "name" in x && typeof (x as any).name === "string", or use a real validation library that keeps the check and the type in sync.