Narrowing and Type Guards
A string | number union is useless until you prove to the compiler which one you are holding. The mechanism for that proof is called narrowing, and it is neither magic nor heuristics: the compiler builds the function's control-flow graph and, for every reference (a variable, this.field, obj.prop), computes its type at each node of that graph independently. A typeof x === 'string' check is an edge in the graph: in the then branch the reference x has type string, in the else branch it has whatever is left of the union. TypeScript emits no runtime code for any of this — the whole analysis happens at compile time on top of ordinary JavaScript you would have written anyway.
Three facts follow, and they are where interviews trip people up. First: narrowing is attached to a reference, not to a value; copy a narrowed obj.prop into a local const and the const keeps the type, while obj.prop re-read inside a callback falls back to its declared one. Second, and the least intuitive: a function call does not discard a narrowing. The compiler does not assume an arbitrary call may have ruined everything — it resets the type only on an assignment it can see in the same flow. This is deliberate unsoundness: the code after mutate() compiles happily with a stale type and blows up at runtime. Third: as and ! are not narrowing. They are assertions the compiler takes on faith and erases; the only things that do real runtime work are a type guard and an assertion function — and a never in the default branch, which turns a forgotten union member into a build error.
Topic map
- Narrowing by control flow — how the compiler computes a reference's type in every branch of the graph, which built-in checks it understands, and where narrowing is lost.
- User-defined type guards — the
p is Fishpredicate as a promise to the compiler that the body is free to break, and why such a promise still needs an honest check. - null, undefined and strictNullChecks — what
strictNullChecksturns on, how??differs from||on zero and the empty string, and why!is a promise rather than a check. - Discriminated unions — the literal-typed discriminant that lets one
switchnarrow a whole union, and the widening that silently destroys it. - Assertion functions —
asserts v is Usernarrows for the rest of the scope rather than one branch, and demands an explicitly annotated call target. - Exhaustiveness checking with never — the
nevertrick indefaultthat turns a new union member into a compile error at everyswitchat once.
Common Mistakes and Traps
| Mistake | Consequence | ||
|---|---|---|---|
| Believing a function call discards a narrowing | It does not — the compiler reacts only to an assignment it can see; after mutate() the type is stale and the code crashes at runtime | ||
Testing for an object with typeof x === 'object' | typeof null is 'object' too — null falls into the "it is an object" branch and dies on the first property access | ||
Expecting the else of if (s) to leave only undefined | A truthiness test also filters out '' and 0 — the else branch still holds the whole `string \ | undefined` | |
| Writing `value \ | \ | defaultValue` for numbers and strings | 0 and '' are falsy: the default replaces a legitimate zero — that is what ?? exists for |
Treating as and ! as checks | Both are erased — no runtime check happens; ! on an actual undefined throws the same TypeError it would have thrown anyway | ||
| Taking a type guard's body at its word | The p is Fish predicate is a promise to the compiler; the body may return true for anything and the compiler will believe it | ||
Forgetting as const or an annotation on a literal with a discriminant | The field widens to string, the discriminant is gone, and the object no longer fits the union — the classic bug | ||
Narrowing with in on an optional key | An optional property rules nothing out: the member survives in both branches, so there is effectively no narrowing | ||
Reaching for instanceof on an interface | Interfaces are erased — instanceof only works against a real constructor function and the prototype chain | ||
Reading a narrowed obj.prop inside a callback | Inside a nested function a mutable reference reverts to its declared type — copy it into a const before the callback | ||
Calling an assertion function through an inferred arrow const | Error: "Assertions require every name in the call target to be declared with an explicit type annotation" | ||
Closing default with a plain throw new Error() | The compiler stays silent: a new union member ships to production and fails at runtime instead of at build time | ||
Expecting the never trick to catch everything | If the union widened to string, or an any leaked in, the assignment to never succeeds and the guard evaporates | ||
Reading ?. as protecting the whole chain after it | a?.b.c returns undefined as a whole when a is nullish, but a.b?.c.d still throws on a — short-circuiting starts only at the ?. | ||
Mixing ?? with `\ | \ | or &&` without parentheses | A syntax error — JavaScript requires explicit parentheses for that combination |
Expecting if (typeof x === 'string') to hold for a let assigned below | An assignment in the flow overwrites the type; the narrowing only holds up to it |
Interview relevance
Narrowing is the main dividing line between "I know TypeScript syntax" and "I understand how the compiler works". Interviewers almost never ask "what is narrowing"; they ask where it breaks. The classic move: have you write a function that tells two union members apart, then add a method call in the middle of it — and watch whether you claim the narrowing survives that call (it does) and that this is a hole in the type system rather than a guarantee.
Typical checks:
- Exactly which checks the compiler understands —
typeof,instanceof,in,Array.isArray, truthiness, comparison against a literal. - That narrowing is control-flow analysis, not a property of the value, and that it is attached to a reference.
- Whether a narrowing survives a function call, and why that is deliberate unsoundness.
- What a
p is Fishtype predicate is, and why it is dangerous. - The difference between
??and||, and the fact that!performs no check at all. - What makes a union discriminated, and why the discriminant's literal type is mandatory.
- How
asserts v is Userdiffers from a predicate. - The
nevertrick, and how it turns adding a member into a build error.
Common wrong answer: "the compiler assumes any call may have changed the value, so the narrowing resets after a call and has to be re-checked." It sounds careful and logical — and it is wrong. TypeScript deliberately does no such effect analysis: it discards a narrowing only on an assignment it sees in the same flow. A candidate convinced otherwise will not spot the real bug — a let reassigned from a callback that, after the call, is still typed string. The second common failure is treating as as a form of narrowing: "but I did check, with as User." You did not: as emits not one byte of runtime code, and it differs from an honest type guard precisely in that it checks nothing.