open atlas
↑ Back to track
TypeScript type system, deep TS · 02 · 04

Discriminated unions

A shared literal tag turns a union into a discriminated union: switch on the tag narrows each branch, and an assertNever default makes adding a variant a compile error everywhere it is unhandled.

TS Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

Your API result type is { loading: boolean; error?: string; data?: User }. Every component checks if (!loading && !error && data) and reaches into data — sometimes data is defined while error is also set, sometimes neither is. The type allows all eight combinations, but only three are real states. A teammate ships a screen that reads data.name during an error and it crashes. The bug is not in the code; it is in the type. The fix is to make illegal states unrepresentable — and the tool for that is the discriminated union.

The pattern: a common literal tag

A discriminated (tagged) union is a union of object types that all share one property — the discriminant — whose type is a distinct literal in each member. Switching or branching on that property narrows the whole object to the matching variant:

type Result =
  | { kind: "ok"; data: string }
  | { kind: "error"; message: string }
  | { kind: "loading" };

function render(r: Result): string {
  switch (r.kind) {
    case "ok":
      return r.data;
      //     ^? (parameter) r: { kind: "ok"; data: string }
    case "error":
      return r.message;
      //     ^? r: { kind: "error"; message: string }
    case "loading":
      return "…";
  }
}

In each case, r is narrowed to the single member whose kind matches the literal, so r.data is reachable in "ok" and r.message in "error" — and nowhere else. This replaces the eight-combination boolean soup from the Hook with three mutually exclusive, fully-typed states. Illegal states (data present during an error) are now unrepresentable.

Requirements for narrowing to work

The checker recognises a discriminated union only when:

  1. The discriminant is a literal type, not a widened primitive. kind: "ok" works; kind: string does not. (Recall the let/const widening from the narrowing lesson — copying the tag into a let breaks this.)
  2. Every member declares the discriminant with a distinct literal. If two members share kind: "ok", the checker cannot tell them apart and merges their fields.
  3. You branch on the discriminant itself (r.kind), not on a derived value.
// Broken: widened discriminant
type Bad = { kind: string; data: string } | { kind: string; err: string };
declare const b: Bad;
if (b.kind === "data") {
  b.data;
  // Error: Property 'data' does not exist on type 'Bad'.
  //   — kind is `string`, not a literal, so no narrowing happens
}

Exhaustiveness with assertNever

The real power is catching missing cases at compile time. When every variant has been handled, the value in the default branch has type never. A helper that accepts only never then forces a compile error the moment a new variant is added but left unhandled:

function assertNever(x: never): never {
  throw new Error("Unexpected variant: " + JSON.stringify(x));
}

function render(r: Result): string {
  switch (r.kind) {
    case "ok": return r.data;
    case "error": return r.message;
    case "loading": return "…";
    default: return assertNever(r);
    //                          ^? r: never  — fine today
  }
}

Now add a fourth variant { kind: "retrying"; attempt: number } to Result and do not touch render. The default branch no longer sees never — it sees { kind: "retrying"; attempt: number }:

// Error: Argument of type '{ kind: "retrying"; attempt: number; }'
//   is not assignable to parameter of type 'never'.

Every switch that forgot the new case lights up red across the codebase. This is the single most valuable property of discriminated unions for senior work: the type system turns “find all the places that need updating” from a manual grep into a guaranteed compile error.

Why this works

Why never and not, say, default: throw? A bare throw runs at runtime; assertNever shifts the failure to compile time. The trick is that never is the empty type: only a value typed never is assignable to a never parameter. While the union is fully handled, the narrowed leftover is never and the call type-checks. The instant the union grows, the leftover is a real type, no longer assignable to never, and the build breaks before anyone runs the code. You get a runtime guard and a compile-time guarantee from the same line.

Why this beats booleans and in

The boolean-flags model (loading, error, data all independent) allows 2^n combinations, most of them illegal. The in-guard model from the previous lesson works but is fragile if keys overlap. A discriminant tag is unambiguous, self-documenting, and — crucially — exhaustiveness-checkable. It is the canonical shape for: API responses (ok/error), Redux/reducer actions (type field), UI/network state machines (idle/loading/success/failure), AST nodes, and parser tokens.

// Redux-style action — the discriminant is conventionally `type`
type Action =
  | { type: "increment"; by: number }
  | { type: "reset" };

function reduce(state: number, a: Action): number {
  switch (a.type) {
    case "increment": return state + a.by;
    case "reset": return 0;
    default: return assertNever(a);
  }
}
Order the steps

Order the steps to add a new variant safely to an exhaustively-checked discriminated union.

  1. 1 Add the new variant (with its literal tag) to the union type
  2. 2 Recompile — every switch with an assertNever default now errors
  3. 3 The default branch no longer narrows to never; it sees the new variant
  4. 4 Add the missing case to each flagged switch
  5. 5 Recompile clean — the leftover is never again

Model a state machine with a discriminant

1/3
Quiz

What is the type of the value in the `default` branch of an exhaustive `switch` over a fully-handled discriminated union?

Quiz

You add a new variant to a discriminated union but forget to handle it in a switch that has an `assertNever` default. What happens?

Recall before you leave
  1. 01
    What makes a union a discriminated union, and what are the requirements for the checker to narrow it?
  2. 02
    Explain the assertNever exhaustiveness pattern and why it shifts failure to compile time.
  3. 03
    Why is a discriminated union better than independent boolean flags or an in-guard for modeling state?
Recap

Discriminated unions are the everyday senior pattern this whole unit builds toward. By giving every member of a union a shared literal tag, you let the checker narrow the entire object by reading that one field: in each case, the variant’s exclusive fields become reachable and illegal combinations become unrepresentable — the antidote to boolean-flag soup. Pair the switch with an assertNever(x: never) default and exhaustiveness becomes a compile-time guarantee: the leftover type is never while every variant is handled, and adding a variant without updating a switch turns into a hard build error at every unhandled site. The one rule that keeps it working: the discriminant must be a literal on every member, never a widened string. Next, index access types show how to derive types from these unions and from data — Result["kind"], Action["type"] — so your tags and value types stay a single source of truth instead of being duplicated by hand. Now when you see { loading: boolean; error?: string; data?: User } in a codebase, you will know exactly what to replace it with — and why the compiler will catch every place that misses the migration.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources1
expand
  1. 01

Trademarks belong to their respective owners. Editorial reference only.