Skip to content
C Codeloom
TypeScript

TypeScript Narrowing and Control Flow Analysis

Learn how TypeScript narrows types through control flow, using typeof, instanceof, in, and discriminated unions to write safer code.

·4 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • What narrowing is
  • typeof and instanceof guards
  • The in operator
  • Discriminated unions
  • Custom type predicates

Prerequisites

  • Comfortable with JS

What and Why

TypeScript narrowing is the process by which the compiler refines a broader type to a more specific one based on runtime checks you write. If a variable is typed string | number and you check typeof x === 'string', inside that branch the compiler treats x as string. This is called control flow analysis: TypeScript tracks branches, assignments, and exits to know what is possible at every line.

Why does this matter? Without narrowing, you would need casts everywhere. With it, your code stays JavaScript-shaped while gaining strong guarantees. Narrowing is the bridge between a flexible type system and ergonomic code.

Mental Model

Think of every variable as carrying a set of possible types. Each control flow construct either shrinks that set or restores it. An if (typeof x === 'string') block subtracts non-string members; the else block keeps the rest. After the block, the set widens again unless the branch exits.

  value: string | number | null
          |
 if (value == null) ----> narrowed: null  (return)
          |
 else if (typeof value === 'string') ----> string
          |
 else --------------------------------> number
How narrowing shrinks a union type along control flow branches

Hands-on Example

Consider a function that prints a value of mixed type.

function describe(value: string | number | null): string {
  if (value == null) return 'nothing';
  if (typeof value === 'string') return value.toUpperCase();
  return value.toFixed(2);
}

Each branch removes possibilities. By the final return, only number remains, so toFixed is safe. Now add a discriminated union for richer data:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(s: Shape) {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;
    case 'square': return s.side ** 2;
  }
}

The kind field is the discriminant; switching on it narrows s to one variant per case. You can also write custom type predicates when built-in guards do not suffice:

function isUser(x: unknown): x is { id: string; name: string } {
  return typeof x === 'object' && x !== null
    && 'id' in x && 'name' in x;
}

Returning x is T teaches the compiler to narrow callers automatically.

Common Pitfalls

A few traps catch most people:

  • Re-widening after function calls. If you narrow a property and then call any function, TypeScript may assume the property changed. Pull the value into a local: const v = obj.value;.
  • Truthy checks on numbers. if (n) excludes 0 as well as undefined, which is rarely what you want. Prefer n != null.
  • Aliasing breaks narrowing. Destructuring before the check, or assigning to another variable, can erase prior refinements.
  • unknown vs any. Use unknown for input you have not validated; any silently disables every check.
  • Bad predicates. A predicate that returns the wrong shape lies to the compiler and will crash at runtime.

Best Practices

Lean on discriminated unions whenever you model variants; they read clearly and narrow effortlessly. Prefer unknown over any at boundaries (network, storage) and write predicates or use a runtime validator. Keep narrowing close to the value: do not pass a partially narrowed object into another function expecting the narrow type unless you encode the guarantee in its signature.

Use the satisfies operator to verify literal shapes without widening, and use never in exhaustive switches to catch missed variants at compile time:

function assertNever(x: never): never { throw new Error(String(x)); }

Wrap-up

Narrowing is one of TypeScript’s most underrated features. Combine typeof, instanceof, in, discriminated unions, and custom predicates, and most casts disappear. Code reads like plain JavaScript, but the compiler is silently proving correctness for you on every branch.