Skip to main content

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:

CategoryMechanismExample
1. Checks via operatorstypeof, instanceof, inif (typeof x === "string")
2. Equality / inequality checks===, !==, ==, !=if (x === null)
3. Truthy / falsy checksif (x), !x, x && y, `x
4. Custom type guardsvalue is Typeif (isString(val))
5. Discriminant checks (discriminated unions)if (obj.kind === "circle") or switch (obj.status)
6. Control flowreturn, throw, break, continueafter return the remaining types are excluded
7. Logical expressions with types!x, x && y, x ? a : beach branch has its own narrowed type
8. Using never after full exclusionwhen TS "understands" no other types remaindefault: in a switch after all cases

Let's look at each category with examples


1. typeof, instanceof, in

javascript
function example(value: string | number | boolean) { if (typeof value === "string") { // value: string } else if (typeof value === "number") { // value: number } else { // value: boolean } }
javascript
if (x instanceof Date) { // x: Date }
javascript
if ("length" in obj) { // obj: { length: number } or similar }

2. Equality checks

javascript
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

javascript
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

javascript
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

javascript
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

javascript
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

javascript
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

javascript
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

  1. If the type is too wide (any), TS does not narrow it:
javascript
let value: any; if (typeof value === "string") { // value: any - TS knows nothing }
  1. If the check does not give unambiguous information:
javascript
let v: string | number; if (v) { // still string | number }
  1. 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:

  1. It scans every instruction (if, return, throw, try, switch);
  2. It builds a "type flow graph";
  3. In every branch it stores the "current type of the variable";
  4. After leaving a block, it merges the possible types back together (union merge).

Example: how TypeScript thinks

javascript
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:

javascript
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 = number

Summary

QuestionAnswer
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 featureIt is automatic control flow analysis (CFA) built into the compiler

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.