This page explains how TypeScript's type narrowing mechanism is implemented internally, covering the data structures, algorithms, and key functions involved. The goal is to help contributors determine whether a given narrowing behavior is a bug or an intentional design decision before filing an issue.
For information on how to file a bug report generally, see 15.5 For background on the type checker's overall design, see 2.3
Type narrowing is the process by which TypeScript refines the static type of a variable or expression at a specific point in the code based on surrounding control flow. When TypeScript sees:
function f(x: string | number) {
if (typeof x === "string") {
// x is narrowed to string here
}
}
it uses control flow analysis to determine that at the point of use inside the if branch, x can only be string.
This involves two phases:
src/compiler/binder.ts): The AST is walked to construct a control flow graph (CFG) of FlowNode objects attached to AST nodes.src/compiler/checker.ts): When the type of a reference is requested, the checker traverses the CFG backwards from the use site, applying narrowing constraints collected along the path.The binder constructs a graph of FlowNode objects. Each node carries a FlowFlags bitmask identifying its kind, along with kind-specific data. All types are defined in src/compiler/types.ts.
| FlowNode Type | FlowFlags member | Created when |
|---|---|---|
FlowStart | FlowFlags.Start | Entry point of every function / source file |
FlowLabel | FlowFlags.BranchLabel / FlowFlags.LoopLabel | Join points after if/else, top of loops |
FlowAssignment | FlowFlags.Assignment | Variable assignment expression |
FlowCondition | FlowFlags.TrueCondition / FlowFlags.FalseCondition | Each branch of if, &&, ||, ??, ternary |
FlowSwitchClause | FlowFlags.SwitchClause | Each case clause of a switch statement |
FlowArrayMutation | FlowFlags.ArrayMutation | .push() / .unshift() calls on an array |
FlowCall | FlowFlags.Call | Calls to assertion functions (e.g. assert(x != null)) |
FlowReduceLabel | FlowFlags.ReduceLabel | Labeled break/continue targets |
Sources: src/compiler/types.ts src/compiler/binder.ts1-304
The binder's bindSourceFile / bind family of functions walk the AST and call createFlowNode at the appropriate points. Key binder logic:
if statements: A FlowCondition with TrueCondition is inserted on the true branch; a FlowCondition with FalseCondition on the false branch. A BranchLabel joins them afterward.&& / || / ??: Each short-circuit point creates a FlowCondition.FlowAssignment is created for every write to a narrowable reference.switch: A FlowSwitchClause wraps each case, referencing the full SwitchStatement and the clause range.return / throw: The successor flow is set to an unreachable node (FlowFlags.Unreachable).The graph edges are implicit: each FlowNode holds a reference to its antecedent node(s).
Diagram: CFG Construction in the Binder
Sources: src/compiler/binder.ts1-304 src/compiler/types.ts
getFlowTypeOfReferenceWhen the checker needs the type of a variable at a specific position, it calls getFlowTypeOfReference in src/compiler/checker.ts. This function:
getTypeAtFlowNode(flow) to traverse the CFG backwards.getTypeAtFlowNodeThis function walks the CFG graph recursively. At each node it encounters:
| FlowNode encountered | Action |
|---|---|
FlowAssignment | Returns the type of the assigned expression |
FlowCondition | Calls narrowType(type, condition.expression, assumeTrue) based on true/false branch |
FlowSwitchClause | Calls narrowTypeBySwitchOnDiscriminant |
FlowCall | Checks if the called function is an assertion function; if so, may narrow |
FlowLabel (BranchLabel) | Collects types from all antecedents and returns their union |
FlowLabel (LoopLabel) | Handles widening of types across loop iterations |
FlowStart | Returns the initial/declared type |
narrowType DispatchnarrowType analyzes the condition expression and dispatches to a specific narrowing function:
Diagram: Narrowing Dispatch Chain
Sources: src/compiler/checker.ts
typeof ChecksHandled by narrowTypeByTypeof. The recognized string literals are: "string", "number", "bigint", "boolean", "symbol", "object", "function", "undefined".
TypeScript maps each typeof result string to a set of TypeFacts bits. Only types whose facts are compatible with the tested typeof value survive after narrowing.
Note: typeof null === "object" is true at runtime. TypeScript models this: null has the TypeofEQObject fact, so after typeof x === "object" the type of x includes null unless strictNullChecks eliminates it.
instanceof ChecksHandled by narrowTypeByInstanceof. TypeScript checks whether the tested type is a subtype of the constructor's instance type. Works on union members individually.
Known limitation: instanceof narrowing only works when the right-hand side is a class or constructor function whose type is statically known. Dynamic constructors may not narrow.
===, !==, ==, !=)Handled by narrowTypeByEquality. Compares the types of both sides:
x === null / x === undefined: removes or isolates null/undefined.x == null: removes both null and undefined (the "nullish" check).x === y where both are variables: intersects their types.Handled by narrowTypeByTruthiness. Used when the condition is a plain expression (not a comparison). Removes falsy members (false, 0, "", null, undefined, 0n) on the true branch; removes truthy members on the false branch.
Known limitation: TypeScript does not track the exact falsy numeric values 0 and -0 as separate types from number. Truthiness narrowing of number produces number on the truthy side (not "nonzero number").
in OperatorHandled by narrowTypeByInKeyword. When "prop" in x is tested, TypeScript narrows x to members of the union that have (or possibly have) property prop.
When a union member has a literal-typed discriminant property (e.g. { kind: "A" } | { kind: "B" }), equality checks on that property narrow the entire union. This is handled inside narrowTypeByEquality and narrowTypeBySwitchOnDiscriminant.
A function with a return type of the form x is T is a type predicate. Calls to such functions are handled by narrowTypeByTypePredicate. After a call that returns true, the argument is narrowed to T; on the false branch, T is removed from the argument's type.
A function annotated asserts x is T (or asserts x) in its return type position is an assertion function. After the call returns (i.e., does not throw), the flow continues with x narrowed to T. These create FlowCall nodes.
return/throw: Makes the subsequent code unreachable; the type checker reports never for references after these.FlowAssignment node always resets the narrowed type to the assigned value's type, overriding prior narrowing.TypeFacts is an internal const enum in src/compiler/checker.ts that encodes what propositions are consistent with a given type. Each type gets a bitmask of facts via the internal getTypeFacts function.
src/compiler/checker.ts1230-1259
| Fact | Bit | Meaning |
|---|---|---|
TypeofEQString | 1 << 0 | typeof x === "string" can be true |
TypeofEQNumber | 1 << 1 | typeof x === "number" can be true |
TypeofEQBigInt | 1 << 2 | typeof x === "bigint" can be true |
TypeofEQBoolean | 1 << 3 | typeof x === "boolean" can be true |
TypeofEQSymbol | 1 << 4 | typeof x === "symbol" can be true |
TypeofEQObject | 1 << 5 | typeof x === "object" can be true |
TypeofEQFunction | 1 << 6 | typeof x === "function" can be true |
EQUndefined | 1 << 16 | x === undefined can be true |
EQNull | 1 << 17 | x === null can be true |
NEUndefined | 1 << 19 | x !== undefined can be true |
NENull | 1 << 20 | x !== null can be true |
Truthy | 1 << 22 | x can be truthy |
Falsy | 1 << 23 | x can be falsy |
IsUndefined | 1 << 24 | Type contains undefined |
IsNull | 1 << 25 | Type contains null |
Composite facts like StringStrictFacts, NumberStrictFacts, etc. combine these flags into pre-computed masks for common primitive types.
During narrowing, a type survives the filter if its TypeFacts mask has the bit required by the condition (for the true branch) or lacks the bit (for the false branch).
Diagram: TypeFacts in the Narrowing Pipeline
Sources: src/compiler/checker.ts1230-1259
Understanding these prevents misidentifying intended behavior as bugs:
| Scenario | Behavior | Reason |
|---|---|---|
| Mutable variable read in a closure | Type widens to declared type | Closure may be called after a reassignment |
typeof x === "object" on string | null | Narrows to null | null has TypeofEQObject; string does not |
Truthiness on number | number on truthy branch (not nonzero) | TypeScript has no "nonzero number" type |
x instanceof C when C is a variable | May not narrow | Narrowing requires the constructor type to be known |
Narrowing after an assignment to x | Prior narrowing is lost | FlowAssignment resets the type |
| Narrowing inside a loop body | May widen across iterations | LoopLabel widens to prevent infinite inference |
x != null with strictNullChecks: false | No narrowing effect | Null/undefined are part of every type in non-strict mode |
| Generic type parameters | Limited narrowing | Type parameters may not overlap with literal types |
A frequent source of confusion: any assignment to x (even within the same if branch, even to the narrowed type) inserts a FlowAssignment node that resets the narrowed type. Subsequent uses after the assignment get the type of the assigned expression, not the previously-narrowed type.
TypeScript only narrows stable references: local variables and parameters that are not aliased into closures and not assigned in control flow branches that could be taken before the use. The reference must be narrowable, determined by the function isNarrowableReference in src/compiler/checker.ts.
Property accesses (x.y) are narrowable only if they are "dotted names" (no computed access) and x is itself stable. Indexing with a variable (x[i]) is not narrowable.
Before filing a bug, verify the following:
--strictNullChecks: Many narrowing behaviors only apply under strict null checks.let? Both can suppress narrowing.x is T predicates silently produce incorrect narrowing that is not a compiler bug.If the behavior persists after the above checks, reference 15.5 for the bug report template. Include:
tsc --noEmit.tsc --version).Type narrowing is exclusively a type-checking-time concept. The emitter (src/compiler/emitter.ts) receives the already-analyzed AST and does not participate in narrowing. The transformation pipeline (src/compiler/) may generate additional flow nodes for transformed code, but narrowing analysis occurs on the original AST before transformation.
Sources: src/compiler/binder.ts src/compiler/checker.ts src/compiler/emitter.ts
Refresh this wiki
This wiki was recently refreshed. Please wait 4 days to refresh again.