When does type narrowing happen?
The key idea
Type narrowing happens when TypeScript gets enough information to rule out some variants from a union or unspecified type.
That is:
- We have a variable with a "wide" type (
string | number | null | undefined | ...), - Some logical condition or check occurs,
- TypeScript concludes: "Given this condition holds, only these variants of the type remain".
When exactly narrowing happens
TypeScript automatically narrows the type on the following events:
| Category | Mechanism | Example |
|---|---|---|
| 1. Checks via operators | typeof, instanceof, in | if (typeof x === "string") |
| 2. Equality / inequality checks | ===, !==, ==, != | if (x === null) |
| 3. Truthy / falsy checks | if (x), !x, x && y, `x | |
| 4. Custom type guards | value is Type | if (isString(val)) |
| 5. Discriminant checks (discriminated unions) | if (obj.kind === "circle") or switch (obj.status) | |
| 6. Control flow | return, throw, break, continue | after return the remaining types are excluded |
| 7. Logical expressions with types | !x, x && y, x ? a : b | each branch has its own narrowed type |
8. Using never after full exclusion | when TS "understands" no other types remain | default: in a switch after all cases |
Let's look at each category with examples
1. typeof, instanceof, in
function example(value: string | number | boolean) {
if (typeof value === "string") {
// value: string
} else if (typeof value === "number") {
// value: number
} else {
// value: boolean
}
}if (x instanceof Date) {
// x: Date
}if ("length" in obj) {
// obj: { length: number } or similar
}2. Equality checks
function greet(name: string | null) {
if (name === null) {
console.log("No name");
} else {
// name: string
console.log("Hello, " + name.toUpperCase());
}
}After if (name === null), TS excludes null from the union in the else branch.
3. Truthy / Falsy checks
function process(user?: { name: string } | null) {
if (!user) {
// user: null | undefined
return;
}
// Here user: { name: string }
console.log(user.name);
}Any "emptiness" check (if (x)) automatically removes null and undefined.
4. Custom Type Guards
function isString(value: unknown): value is string {
return typeof value === "string";
}
function print(value: unknown) {
if (isString(value)) {
// value: string
console.log(value.toUpperCase());
}
}TS trusts the isString function - if it returns true,
then inside that branch the type of value is definitely string.
5. Discriminated unions
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape) {
if (shape.kind === "circle") {
// shape: { kind: "circle"; radius: number }
return Math.PI * shape.radius ** 2;
} else {
// shape: { kind: "square"; side: number }
return shape.side ** 2;
}
}TS narrows the type by the discriminant (kind).
6. Control flow
function example(x: string | null | undefined) {
if (x == null) return; // excludes null and undefined
// Here x: string
console.log(x.toUpperCase());
}TypeScript sees that after return the "branch" with null/undefined is cut off,
and only the remaining variants continue.
7. Logical expressions
function showName(user?: { name: string } | null) {
// user?.name narrows the type inside
const name = user && user.name; // user: { name: string } | null | undefined
if (name) {
// name: string
console.log(name.toUpperCase());
}
}8. Narrowing to never
function handle(val: "a" | "b") {
switch (val) {
case "a":
return "Got A";
case "b":
return "Got B";
default:
const _exhaustive: never = val; // TS guarantees this is unreachable
return _exhaustive;
}
}TypeScript understands that after handling all possible cases,
the type has narrowed to never - no variants remain.
When narrowing does not happen
- If the type is too wide (
any), TS does not narrow it:
let value: any;
if (typeof value === "string") {
// value: any - TS knows nothing
}- If the check does not give unambiguous information:
let v: string | number;
if (v) {
// still string | number
}- If TS cannot track control flow (for example, when a variable is mutated).
At what point in compilation this happens
TypeScript performs control flow analysis (CFA) - an analysis of the program's execution path, step by step:
- It scans every instruction (
if,return,throw,try,switch); - It builds a "type flow graph";
- In every branch it stores the "current type of the variable";
- After leaving a block, it merges the possible types back together (union merge).
Example: how TypeScript thinks
let x: string | number | null;
if (x === null) {
// x: null
} else if (typeof x === "string") {
// x: string
} else {
// x: number
}Internally, TS does roughly this:
Initial type: string | number | null
-> if (x === null): branch 1 = null
-> else: branch 2 = string | number
-> if (typeof x === "string"): branch 2a = string
-> else: branch 2b = numberSummary
| Question | Answer |
|---|---|
| When does narrowing happen? | When TypeScript sees a logical check that excludes part of the possible types |
| What exactly does it do? | It "drops" types that are impossible in the current branch |
| Based on what? | typeof, instanceof, in, ===, ==, if (x), return, throw, type guards, discriminants |
| When does it stop? | After leaving the branch, TS merges all possible types back together |
| Key feature | It is automatic control flow analysis (CFA) built into the compiler |
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.