What does "control flow based narrowing" mean?
What Control Flow Based Narrowing Is
Control flow-based narrowing is a TypeScript mechanism that analyzes the program's control flow and automatically narrows the type of variables based on logical conditions, checks, and code branches.
Simply put:
TypeScript tracks which checks you perform, and "understands" which types remain possible at every point in the program.
Example 1. A basic example - if
function printId(id: string | number) {
if (typeof id === "string") {
// Here TS sees: id is definitely string
console.log(id.toUpperCase());
} else {
// And here: id is definitely number
console.log(id.toFixed(2));
}
}TypeScript analyzes the control flow (if/else),
and narrowed the type of id in each branch.
"Control flow" is the path the program takes at runtime
Internally, TypeScript builds a control flow graph (CFG):
- every
if,else,return,throw,switch,try/catchbranch is a separate "branch" of the flow; - in each branch, variables can have different types;
- when flows return to a common block, TS "merges" the types back together.
Visually:
let x: string | number;
if (typeof x === "string") {
// branch 1 -> x: string
} else {
// branch 2 -> x: number
}
// after the if -> x: string | number (merged)Example 2. Null and undefined checks
function greet(name?: string | null) {
if (!name) return; // filters out null and undefined
// Here name: string
console.log("Hello, " + name.toUpperCase());
}TypeScript tracks that after if (!name) return,
the possible types null and undefined are excluded from the union.
Example 3. Narrowing through return and throw
TypeScript understands that if execution is interrupted, then the remaining branch has a different type.
function ensureString(value: string | undefined): string {
if (value === undefined) {
throw new Error("Expected string");
}
// Here value: string (undefined excluded)
return value.toUpperCase();
}TS knows: "if the throw did not run, then value is definitely not undefined".
Example 4. Narrowing through in, instanceof, typeof
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function speak(pet: Cat | Dog) {
if ("bark" in pet) {
// pet: Dog
pet.bark();
} else {
// pet: Cat
pet.meow();
}
}TypeScript uses a property check as logical proof, to exclude a type that does not match.
Example 5. A combined narrowing flow
function example(value: string | number | null) {
if (value == null) {
// value: null
return;
}
if (typeof value === "string") {
// value: string
console.log(value.toUpperCase());
} else {
// value: number
console.log(value.toFixed(2));
}
}TS analyzes the control flow step by step:
starting type: string | number | null
-> after if (value == null): null excluded
-> after if (typeof value === "string"): number excludedExample 6. Narrowing on assignment
let x: string | number = 42;
if (typeof x === "number") {
x = x + 1; // x: number
} else {
x = x.toUpperCase(); // x: string
}TypeScript even tracks how types change after an assignment, which is also part of control flow analysis.
Example 7. Narrowing inside ternary operators
function format(value: string | number) {
return typeof value === "string" ? value.toUpperCase() : value.toFixed(2);
}TS narrows the types on each branch of the ternary expression.
Example 8. Combined with never
TypeScript can "understand" that all variants have been handled,
and the remaining type becomes never.
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; s: number };
function area(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.r ** 2;
case "square":
return shape.s ** 2;
default:
const _exhaustive: never = shape; // TS checks that this is unreachable
return _exhaustive;
}
}This is an example of discriminated narrowing,
based on flow analysis (switch/case).
How TypeScript does this "under the hood"
- It builds a control flow graph:
- every branching point (
if,return,throw,catch) creates a node.
- It assigns possible types to every node.
- It tracks exceptions and returns, where a branch terminates.
- It merges types back together when flows converge.
This is what is called control flow analysis: analyzing types in the context of the program's execution.
Example 9. TS understands even complex expressions
function test(a?: number, b?: number) {
if (a && b) {
// a: number, b: number
} else if (a) {
// a: number, b: undefined
} else {
// a: undefined, b: number | undefined
}
}TS computes the types in every branch depending on which checks succeeded.
Example 10. Narrowing after a try/catch check
function safeParse(input: string): object | null {
try {
return JSON.parse(input);
} catch {
return null;
}
}TS knows:
- inside
try, the result isobject, - inside
catch, it isnull. This is also control flow.
Why this matters
Without "control flow based narrowing", TypeScript would just be a "type validator" with no understanding of the program's logic.
But with it:
- the compiler analyzes real branches and checks;
- types are automatically narrowed wherever it is logically justified;
- the IDE shows context-aware hints;
- you can stop being afraid of
null,undefined,unionandunknown- TS protects you from errors at the level of the execution flow.
Summary
| Term | Meaning |
|---|---|
| Control flow | The program's execution path: if, else, return, throw, switch, try |
| Narrowing | Narrowing the set of possible types down to a specific one |
| Control flow-based narrowing | When TS automatically narrows types by analyzing the code's logic and branches |
| Main idea | A variable's type changes depending on what has been "proven" in the current branch |
| Result | Less as, fewer errors, more safety and better autocomplete |
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.