Discriminated Unions and Exhaustive Checks

Intermediate
11 min

Discriminated Unions and Exhaustive Checks

A discriminated union (also called a tagged union) is a union of object types that share one literal-typed property, the discriminant. Checking that property tells TypeScript exactly which member you are holding. Combined with the never type, it lets the compiler prove that every case is handled, so adding a new variant produces compile errors in every place that forgot it. This is the single most useful pattern for modeling state in TypeScript.

Anatomy of a Discriminated Union

typescript
interface Circle { kind: "circle"; radius: number } interface Square { kind: "square"; side: number } interface Triangle { kind: "triangle"; base: number; height: number } type Shape = Circle | Square | Triangle;

Three rules make this work:

  1. Every member has the same property name (kind).
  2. Each member gives it a different literal type ("circle", "square", ...).
  3. The other properties can differ freely.

Now any comparison on kind narrows the union:

typescript
function area(shape: Shape): number { if (shape.kind === "circle") { return Math.PI * shape.radius ** 2; // shape: Circle } if (shape.kind === "square") { return shape.side ** 2; // shape: Square } return (shape.base * shape.height) / 2; // shape: Triangle (only option left) }

Accessing shape.radius before narrowing is an error because radius does not exist on every member. The discriminant does not have to be a string: numeric literals, booleans and enum members work too, and the property is conventionally named kind, type, status or tag.

Exhaustive Checks With never

never is TypeScript's bottom type: it has no values. A function that always throws returns never, an impossible intersection collapses to never, and a union that has been fully narrowed becomes never. That last property is what makes exhaustiveness checking possible:

typescript
function assertNever(value: never): never { throw new Error(`Unexpected value: ${JSON.stringify(value)}`); } function describe(shape: Shape): string { switch (shape.kind) { case "circle": return "round"; case "square": return "four equal sides"; case "triangle": return "three sides"; default: return assertNever(shape); // shape is never here } }

If someone later adds { kind: "hexagon" } to Shape, the default branch receives a Hexagon instead of never, and the call to assertNever fails to compile with "Argument of type 'Hexagon' is not assignable to parameter of type 'never'". The compiler has found every switch that needs updating.

A lighter alternative uses satisfies:

typescript
default: { const _exhaustive: never = shape; return _exhaustive; } // or, without an unused variable: default: shape satisfies never; throw new Error("unreachable");

Enabling noFallthroughCasesInSwitch and noImplicitReturns in tsconfig.json adds further protection for switch-heavy code.

Modeling State Machines

Discriminated unions shine wherever a value can be in one of several mutually exclusive states. Compare the two designs below:

typescript
// Weak: every field is optional and nonsense combinations are allowed interface FetchStateLoose { loading: boolean; data?: string[]; error?: string; } // Strong: each state carries exactly the data that exists in that state type FetchState = | { status: "loading" } | { status: "success"; data: string[] } | { status: "error"; error: string };

With FetchStateLoose, { loading: true, data: [...], error: "x" } is valid and the UI has to guess. With FetchState, data is only reachable after checking status === "success", so impossible states cannot be represented. The same idea applies to Redux-style actions ({ type: "add"; item: Item } | { type: "remove"; id: number }), API responses, form steps and parser tokens.

Narrowing Helpers

  • switch (true) narrowing (TypeScript 5.3+) lets each case be an arbitrary boolean expression, useful when the discriminant is a range or a computed check.
  • Destructured discriminants (TypeScript 4.6+): after const { type, payload } = action;, a check on type narrows payload, provided the destructuring is const and every member declares both properties.
  • A union of tuples such as [ "ok", Data ] | [ "err", Error ] is discriminated by index 0.

Common mistakes

  • Declaring the discriminant as kind: string instead of a literal. A string cannot distinguish members, so nothing narrows.
  • Forgetting the default branch. Without it, a switch silently returns undefined for new variants.
  • Reassigning the variable between the check and the use, which resets the narrowing.
Quick Quiz
Question 1 of 3

What property makes a union "discriminated"?

Key Takeaways

  • A discriminated union shares one literal-typed property whose value identifies the member.
  • Comparing the discriminant in if or switch narrows the whole object to that member.
  • never is the bottom type; passing the fully narrowed value to assertNever proves exhaustiveness at compile time.
  • Model state as a union of exact shapes instead of a bag of optional fields, so impossible states are unrepresentable.
  • Adding a variant later triggers compiler errors in every unhandled switch, which is exactly what you want.

Next lesson: Function Overloads and Typing this — give one function several signatures and type the this value explicitly.

Discriminated Unions and Exhaustive Checks - TypeScript | CodeYourCraft | CodeYourCraft