Skip to main content

Unions & Narrowing: Modeling Reality

intermediate15 min readLesson 87 of 143

Union types for either/or data, discriminated unions with a kind tag, exhaustiveness with never, and runtime type guards.

Real data is often "one of several shapes". Unions model that honestly; the compiler then forces you to handle every case.

Narrowing

TypeScript follows your runtime checks:

function format(value: string | number) {
  if (typeof value === "string") return value.trim(); // here: string
  return value.toFixed(2); // here: number
}

typeof, instanceof, in, and truthiness all narrow — no casts needed when the check is real.

Discriminated unions

Give each variant a literal tag and every switch becomes checked:

type Request =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "success"; data: string[] }
  | { kind: "error"; message: string };

function render(r: Request) {
  switch (r.kind) {
    case "idle":
      return "Nothing yet";
    case "loading":
      return "Loading…";
    case "success":
      return r.data.join(", "); // r.data exists ONLY here
    case "error":
      return r.message;
    default: {
      const _never: never = r;
      return "";
    }
  }
}

The default trick: assigning to never fails to compile the moment someone adds a variant without handling it — exhaustiveness enforced by the type system.

Type guards

A predicate function narrows wherever it is used:

function isUser(v: unknown): v is User {
  return typeof v === "object" && v !== null && "id" in v && "email" in v;
}

That v is User return type is the contract between runtime checking and compile-time knowledge — the heart of the boundary practice below.

Now practice

Unions & Narrowing — PracticeRuntime narrowing: typeof-based formatters, a discriminated-union reducer, and exhaustive switches with a default that throws.3 challenges · · ~18 min