Narrowing and control-flow analysis
Control-flow analysis tracks the type of a variable at each program point. typeof, truthiness, in, instanceof and assignment narrow a union — but a closure or await can re-widen it.
You guard a value with if (typeof id === "string") and inside the block the checker happily lets you call id.toUpperCase(). Then you wrap that call in a setTimeout callback — and suddenly the checker says id is string | number again. Nothing about id changed. What changed is where the code runs relative to the guard. Narrowing is not a property of the variable; it is a property of the program point. Understanding how the checker computes the type at each point — and when it gives up — is the difference between fighting the compiler and steering it.
The checker tracks a type per program point
A union variable does not have a single type throughout its scope. The checker walks the control-flow graph and, at each point, holds the narrowest type still consistent with the guards taken to reach that point. A guard splits the flow: the true branch carries one narrowing, the false branch carries the complement.
function format(x: string | number) {
// x: string | number
if (typeof x === "string") {
x;
// ^? (parameter) x: string
return x.toUpperCase();
}
x;
// ^? (parameter) x: number — the complement of the string branch
return x.toFixed(2);
}The else (here the fall-through after return) gets number for free: the checker subtracts string from the union because that branch was already handled. This is why an early return inside a guard narrows the rest of the function.
The five everyday narrowing operators
type Val = string | number | boolean | null | string[];
function inspect(v: Val) {
if (typeof v === "number") v; // ^? number (typeof guard)
if (v === null) return; // equality guard → below, v excludes null
if (Array.isArray(v)) v; // ^? string[] (built-in guard)
if (v) v; // ^? string | number | boolean | string[] (truthiness drops null)
}The everyday tools, each narrowing in a slightly different way:
typeof— splits on JS runtime type tags ("string","number","boolean","object","function","undefined","symbol","bigint"). Notetypeof null === "object", a JS quirk the checker respects.- Truthiness —
if (v)removesnull,undefined,0,"",false,NaNfrom the type. Great forstring | null, dangerous fornumber | undefined(it also drops0). - Equality —
===/!==against a literal ornull/undefinednarrows both branches. in—if ("radius" in shape)narrows to the union members that declare that property. Structural, not nominal.instanceof— narrows to the class (and subclasses), using the prototype chain at runtime.
▸Edge cases
typeof v === "object" is treacherous. It is true for arrays, null, and plain objects alike. The checker narrows string | number[] | null under that guard to number[] | null (both are “object” at runtime), not to number[]. You still have null to deal with. Prefer Array.isArray and explicit === null checks over a bare typeof === "object".
Assignment narrowing and the let/const difference
Assigning a value narrows the variable to the assigned value’s type from that point forward — but only as far as the declared type allows.
let s: string | number;
s = "hello";
s;
// ^? string — assignment narrowed it
s.toUpperCase(); // OK here
declare function rand(): string | number;
s = rand();
s;
// ^? string | number — re-widened to the declared typeconst differs from let for literal inference, which feeds narrowing. A const binding to a literal gets the literal type; a let binding widens to the base type:
const dir = "up";
// ^? "up" — literal type, usable as a discriminant
let dir2 = "up";
// ^? string — widened, no longer a discriminantThis matters enormously for discriminated unions (next-but-one lesson): a discriminant read from a const keeps its literal type; copied into a let it widens and narrowing silently stops working.
Exhaustiveness with never
When you have narrowed away every member of a union, the remaining type is never. Assigning that to a never-typed variable turns “I forgot a case” into a compile error:
function area(s: { kind: "circle"; r: number } | { kind: "square"; side: number }): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.side ** 2;
default: {
const _exhaustive: never = s;
// If a new variant is added, s is no longer never here →
// Error: Type '{ kind: "triangle"; ... }' is not assignable to type 'never'.
return _exhaustive;
}
}
}This is the senior pattern for safe refactors: add a variant, and every unhandled switch lights up red. You will formalize it next.
Narrowing is lost across function boundaries
This is the pitfall that catches everyone. The checker assumes any function call between a guard and a use could have mutated a captured variable, so it discards the narrowing for variables it cannot prove are unaffected:
function handle(id: string | number) {
if (typeof id === "string") {
id;
// ^? string
setTimeout(() => {
id;
// ^? string | number — re-widened inside the closure!
// id.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'number'.
});
}
}The closure may run later, after id could (in principle) have been reassigned, so the checker conservatively re-widens it to the declared type. The same happens after an await, because control left and re-entered the function. The fixes: copy the narrowed value into a const before the boundary (const sid = id; while id is string), or make the captured binding a const so the checker knows it cannot be reassigned.
function handle(id: string | number) {
if (typeof id === "string") {
const sid = id; // const captures the narrowed string
setTimeout(() => sid.toUpperCase()); // OK — sid is string
}
}Order the steps the checker performs to type a variable inside a guarded branch.
- 1 Start with the variable's declared (possibly union) type
- 2 Reach a guard expression (typeof / in / equality)
- 3 Split control flow: true edge and false edge
- 4 On the true edge, keep only members consistent with the guard
- 5 Re-widen to the declared type if a function call or await intervenes
Why does the type widen again?
1/3Inside `setTimeout(() => { /* use id */ })`, a previously narrowed `id` is reported as `string | number` again. Why?
For `v: number | undefined`, why is `if (v) { ... }` a risky narrowing guard?
- 01Explain what control-flow analysis computes and why a variable has different types at different lines.
- 02List the five everyday narrowing operators and one gotcha for each.
- 03Why is narrowing lost across a closure or await, and how do you preserve it?
Narrowing is the checker’s control-flow analysis in action: at each program point it holds the narrowest type consistent with the guards on the path there, so the same variable can be string on one line and number on the next. The everyday operators — typeof, truthiness, equality, in, instanceof — each split the flow and keep the consistent members, the false branch inherits the complement, and an exhausted union becomes never (the basis for exhaustiveness checks). The traps to internalize: truthiness drops 0 from numeric unions, let widens literal discriminants, and a closure or await re-widens to the declared type unless you capture the narrowed value in a const first. Next, type guards let you write your own predicate functions that participate in exactly this control-flow machinery. Now when you see a type widen inside a callback or after an await, you will reach for const sid = id before the boundary rather than fighting the compiler.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.