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
Shapeis a union of three object types that all share akindproperty with a distinct string literal — the "discriminant."- Inside each
case, TypeScript narrowsshapeto just that variant, soshape.radiusis only visible in the"circle"branch and so on. assertNevertakes a parameter typednever. After the three cases, if every variant has been handled, TypeScript has narrowedshapedown tonevertoo (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
Shapeparsed from JSON with an unexpectedkind.
Gotchas
- The never check only helps if you call
assertNeverin thedefaultbranch. Adefaultthat 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 onshape.kindsome other way, like anif/elsechain.
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.