Discriminated unions and exhaustiveness checking
A plain union type narrows fine with typeof checks for primitives, but for a union of OBJECT shapes, TypeScript needs a shared, literal-typed field to narrow on — and once that field exists, the compiler can also guarantee every case was actually handled, at compile time.
3 min read
The problem: narrowing a union of object shapes isn't as automatic as narrowing primitives
type Circle = { radius: number };
type Square = { sideLength: number };
type Shape = Circle | Square;
function area(shape: Shape): number {
if ("radius" in shape) { // works, but fragile — relies on checking for a PROPERTY'S presence
return Math.PI * shape.radius ** 2;
}
return shape.sideLength ** 2;
}The earlier type-narrowing lesson covered narrowing primitives with typeof — but a union of two object shapes has no single, obvious runtime check the way typeof x === "string" does. Checking "radius" in shape works here, but it's checking an incidental detail (does this property exist) rather than something the types were actually designed to be checked by — it gets fragile fast as more shapes and more overlapping-but-different fields get added.
The fix: a shared, literal-typed field that exists specifically to be checked
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; sideLength: number };
type Shape = Circle | Square;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // TypeScript KNOWS shape is Circle here
case "square":
return shape.sideLength ** 2; // and KNOWS shape is Square here
}
}Adding a kind field with a distinct literal type ("circle", "square") to each member of the union — called the discriminant — gives TypeScript a genuine, designed-for-this-purpose field to narrow on. Inside case "circle":, TypeScript doesn't just let the code run; it actually narrows shape's type to Circle specifically, because it can see that only Circle has kind: "circle" — the exact same narrowing mechanism the type-narrowing lesson covered, just driven by a field designed for it rather than an incidental property check.
Exhaustiveness checking: making the compiler catch a forgotten case
type Shape = Circle | Square | Triangle; // a NEW shape added later
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.sideLength ** 2;
default: {
const _exhaustive: never = shape; // COMPILE ERROR if any case was missed — shape isn't `never` here
throw new Error(`Unhandled shape: ${JSON.stringify(shape)}`);
}
}
}The never type (from the any/unknown/assertions lesson's neighborhood of concepts) represents a value that should be genuinely impossible to reach — assigning shape to a variable typed never in the default case only type-checks if TypeScript has already narrowed every other case away, leaving nothing. If a new shape (Triangle) gets added to the union but its case is forgotten in the switch, shape in the default branch is no longer narrowed down to nothing — it's still Triangle there — and assigning it to never becomes a real, immediate compile error, catching the missing case at build time rather than as a silent runtime bug.
Why this pattern shows up constantly in real, production TypeScript
Discriminated unions are the standard, idiomatic way to model "one of several distinct variants, each with different data" in TypeScript — API responses ({status: "success", data} | {status: "error", message}), Redux-style actions ({type: "increment"} | {type: "setValue", value}), and state machines (this platform's OOP domain's State pattern, expressed as data instead of classes) are all real, common instances of this exact shape. The discriminant field (often literally named kind, type, or status) is what makes each of these safe to work with — the compiler actively prevents accessing a field that doesn't exist on the currently-narrowed variant, and exhaustiveness checking prevents silently forgetting to handle a new variant as the union grows over time.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why does checking `"radius" in shape` to narrow a union of object shapes work, but remain fragile?
2. What does adding a `kind: "circle"` literal field to each member of a union actually enable?
3. How does assigning to a `never`-typed variable in a switch's default case catch a forgotten union member?