TypeScript by Example

Narrowing

Control-flow analysis, type guards, assertion functions, and exhaustive checks - how TypeScript proves a value's type at each point in the program.

TypeScript tracks the type of every value at every point in your control flow. When you check a type, TypeScript "narrows" the union inside that branch so you can safely access variant-specific properties without a cast.

typeof narrows primitive types. instanceof narrows class instances. The in operator narrows by property presence. Equality checks narrow by value. TypeScript applies each to the right branch automatically.

function process(value: string | number | Date): string {
  if (typeof value === "string") {
    return value.toUpperCase(); // value: string
  }
  if (value instanceof Date) {
    return value.toISOString(); // value: Date
  }
  return value.toFixed(2); // value: number
}
 
type Circle = { kind: "circle"; radius: number };
type Rect   = { kind: "rect"; width: number; height: number };
type Shape  = Circle | Rect;
 
function describe(shape: Shape): string {
  if ("radius" in shape) {
    return `circle r=${shape.radius}`; // shape: Circle
  }
  return `rect ${shape.width}×${shape.height}`; // shape: Rect
}

Truthiness narrowing removes null, undefined, 0, and "" from the type. Assignment narrowing refines the type each time a variable is assigned - TypeScript tracks all assignment sites in the function.

function greet(name: string | null): string {
  if (!name) {
    return "Hello, stranger"; // name is falsy here
  }
  return `Hello, ${name}`; // name: string (null removed)
}
 
// Assignment narrowing
let id: string | number = Math.random() > 0.5 ? "abc" : 42;
 
if (typeof id === "string") {
  id = id.toUpperCase(); // id: string
} else {
  id = id * 2; // id: number
}
 
// Nullable field narrowing
type Profile = { avatar: string | undefined };
 
function renderAvatar(profile: Profile): string {
  const { avatar } = profile;
  if (!avatar) return "/default-avatar.png"; // avatar is falsy: type stays string | undefined
  return avatar; // avatar: string
}

A user-defined type guard is a function whose return type is a type predicate (arg is Type). When the function returns true, TypeScript narrows the argument to the specified type in the calling scope.

type Cat = { kind: "cat"; meow(): void };
type Dog = { kind: "dog"; bark(): void };
type Animal = Cat | Dog;
 
function isCat(animal: Animal): animal is Cat {
  return animal.kind === "cat";
}
 
function makeSound(animal: Animal): void {
  if (isCat(animal)) {
    animal.meow(); // animal: Cat
  } else {
    animal.bark(); // animal: Dog
  }
}
 
// Type guard for unknown API responses
function isStringArray(value: unknown): value is string[] {
  return (
    Array.isArray(value) && value.every((item) => typeof item === "string")
  );
}
 
const raw: unknown = JSON.parse('["a", "b", "c"]');
if (isStringArray(raw)) {
  console.log(raw.join(", ")); // raw: string[]
}

An assertion function (asserts arg is Type) throws if the assertion fails and narrows the type for all code that follows. The exhaustive never check turns a switch on a discriminated union into a compile-time registry - adding a new variant without handling it is a type error.

function assertDefined<T>(value: T | null | undefined, msg: string): asserts value is T {
  if (value == null) throw new Error(msg);
}
 
let maybeUser: { name: string } | null = null;
// assertDefined(maybeUser, "user required"); // throws at runtime if null
// After the call, maybeUser is narrowed to { name: string }
 
// Exhaustive switch - adding a new variant without a case is a type error
type Action =
  | { type: "INCREMENT"; by: number }
  | { type: "RESET" }
  | { type: "SET"; value: number };
 
function reduce(count: number, action: Action): number {
  switch (action.type) {
    case "INCREMENT": return count + action.by;
    case "RESET":     return 0;
    case "SET":       return action.value;
    default: {
      // If Action gains a new variant and this case is missing, _: never is a type error
      const _: never = action;
      throw new Error(`Unhandled action: ${JSON.stringify(_)}`);
    }
  }
}

In production

A discriminated union + switch (action.type) with default: const _: never = action is a compile-time registry - adding a new variant without handling it is a type error everywhere the switch appears. This pattern replaces the Visitor pattern in most TypeScript codebases: it's simpler, serializes to JSON without adapters, and the compiler enforces exhaustiveness. Assertion functions (asserts value is T) are the right tool for validating data at trust boundaries (API responses, config loading) - they narrow the type for all downstream code without needing an if at every call site. Prefer unknown over any for unvalidated input: unknown forces you to narrow before use; any bypasses the type checker silently.

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free