10LOC
TypeScriptintermediate

Discriminated unions with an exhaustive switch and a never check

Published July 28, 2026

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rectangle"; width: number; height: number };

const assertNever = (value: never): never => {
  throw new Error(`Unhandled shape: ${JSON.stringify(value)}`);
};

export const area = (shape: Shape): number => {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.side ** 2;
    case "rectangle": return shape.width * shape.height;
    default: return assertNever(shape);
  }
};

What

area switches over Shape's kind tag and handles every variant explicitly. The default branch passes whatever's left to assertNever, whose parameter type is never — so it only compiles if TypeScript has already narrowed every other case away.

Why it matters

A switch without a default looks exhaustive until someone adds a fourth shape to the union and forgets to add a case. Without the never check, that fourth shape falls through the switch, area returns undefined, and the bug surfaces three files away from where it was introduced. With the never check, the same change is a compile error at the assertNever(shape) call: shape is no longer never once a case is missing, so TypeScript flags the mismatch immediately, at the exact spot that needs updating.

How it works

  • Shape is a union of three object types that all share a kind property with a distinct string literal — the "discriminant."
  • Inside each case, TypeScript narrows shape to just that variant, so shape.radius is only visible in the "circle" branch and so on.
  • assertNever takes a parameter typed never. After the three cases, if every variant has been handled, TypeScript has narrowed shape down to never too (there's nothing left it could be) — so the call type-checks.
  • It also throws at runtime. That matters for values that bypass the type system entirely, like a Shape parsed from JSON with an unexpected kind.

Gotchas

  • The never check only helps if you call assertNever in the default branch. A default that just returns some fallback value compiles fine even with an unhandled case — silently.
  • This only checks exhaustiveness of the switch, not of anywhere else that might branch on shape.kind some other way, like an if/else chain.

Related

  • Pattern matching in languages like Rust or Swift makes this exhaustiveness check a language feature; TypeScript's version is a library-level convention, not enforced by the compiler unless you add the never check yourself.
  • satisfies — a different way to lean on the type checker for a related-but-distinct goal: validating a value's shape without widening it.