Discriminated Unions & Exhaustiveness
Tag, narrow, exhaust — the safest pattern for variant data.
Discriminated Unions & Exhaustiveness
A discriminated union (sometimes called a tagged union or sum type) is the right way to model "this value is one of N shapes". Combined with exhaustiveness checks, it gives you the strongest compile-time safety TypeScript has to offer: adding a new variant breaks the build everywhere you forgot to handle it.
The shape
A discriminated union is a union of object types that share a common literal field — the discriminator:
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; s: number }
| { kind: "triangle"; base: number; height: number };
Three rules to recognise one:
- It's a union (
A | B | C). - Every member is an object type.
- They share a field whose type is a literal (
"circle",1,true).
The literal is critical. kind: string doesn't narrow — TypeScript can't tell "circle" from "square" if the type says "any string". kind: "circle" is a value, not a category.
Narrowing on the discriminator
Once you check the discriminator, TS narrows the union:
function area(s: Shape): number {
if (s.kind === "circle") {
// here, s: { kind: "circle"; r: number }
return Math.PI * s.r ** 2;
}
if (s.kind === "square") {
return s.s ** 2;
}
return 0.5 * s.base * s.height;
}
Each branch sees only the variants whose discriminator matches. s.r works in the circle branch because TS proved s is the circle variant. It would be a type error in the square branch.
This is control-flow analysis — TS reads your conditions and rules out impossible types in each branch. It's the most magical thing TS does, and discriminated unions are the cleanest way to take advantage of it.
Why a switch is better than if/else
Two reasons:
- The discriminator is the obvious switch expression — readability.
- The default branch is the natural place for the exhaustiveness check.
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.s ** 2;
case "triangle": return 0.5 * s.base * s.height;
default: {
const _exhaustive: never = s; // ★ exhaustiveness check
return _exhaustive;
}
}
}
The exhaustiveness check, line by line
default: {
const _exhaustive: never = s;
return _exhaustive;
}
After all the explicit case branches have run, TS narrows s to whatever variants are left. If you've handled every variant, that residual type is never (the empty type). Assigning a never value to a never variable is fine.
The day someone adds a new variant:
type Shape = ... | { kind: "ellipse"; rx: number; ry: number };
The residual type in default becomes { kind: "ellipse"; rx: number; ry: number }. Assigning that to never is a type error. CI fails. You're forced to handle the new variant.
That's the safety property. Adding a variant fails the build everywhere it isn't handled. No silent runtime crashes, no forgotten cases.
The assertNever helper
Most codebases factor the check into a one-liner:
function assertNever(x: never): never {
throw new Error("Unreachable: " + JSON.stringify(x));
}
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.s ** 2;
case "triangle": return 0.5 * s.base * s.height;
default: return assertNever(s);
}
Same compile-time guarantee, also throws at runtime if someone reaches it via as Shape shenanigans.
What can be a discriminator?
Any field whose type is a literal works:
- String literals are most common:
kind: "circle",type: "user",_tag: "Loading". - Number literals work:
code: 200 | 404 | 500. - Boolean technically works (
{ ok: true; data: T } | { ok: false; error: E }) — extremely common forResulttypes. - Multiple discriminator fields can intersect: TS can narrow on
(state.kind === "loading" || state.kind === "error")etc.
Avoid null/undefined as discriminators — there's exactly one such value, so they only distinguish two cases and the resulting types are awkward.
A real-world example
The pattern works wonderfully for state machines:
type RequestState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "success"; data: User[] }
| { kind: "error"; error: Error };
function render(s: RequestState) {
switch (s.kind) {
case "idle": return <Idle />;
case "loading": return <Spinner />;
case "success": return <Users users={s.data} />;
case "error": return <ErrorBanner error={s.error} />;
default: return assertNever(s);
}
}
The data-only fields (data, error) live on the variants where they make sense. There's no "data is undefined when loading" — the type prevents you from accessing s.data when s.kind is anything other than "success".
The OOP comparison
If you came from a class-based language, you'd probably reach for:
abstract class Shape { abstract area(): number; }
class Circle extends Shape { constructor(public r: number) { super(); } /* … */ }
class Square extends Shape { constructor(public s: number) { super(); } /* … */ }
Both work. Tagged unions trade away polymorphism for these benefits:
- Variants don't need a common method signature; each is just data.
- All logic lives in one place, not split across N classes.
- Easier to serialise (it's just JSON), easier to test (no mocking).
- Exhaustiveness check is structural — works without inheritance gymnastics.
For data-shaped variants, tagged unions usually win. For behaviour-shaped variants (where each subtype overrides a method with very different logic), classes can still be cleaner.
Pitfalls
Don't make the discriminator optional. kind?: "circle" defeats narrowing — the value could be undefined, and TS can't rule out other variants.
Don't share field names with non-literal types across variants. { kind: "loading"; data: undefined } | { kind: "success"; data: User[] } — both have data, but the type of data post-narrow depends on which variant. Clean, but easy to subtly break.
Don't skip the exhaustiveness check. Without it, adding a variant is a silent fall-through. The whole point of using a tagged union is the safety net; remove the net and you're back to runtime errors.
The pattern is small, mechanical, and bulletproof. Use it everywhere a value can be one of several shapes.
Tools in the wild
3 tools- libraryts-patternfree tier
Pattern matching for TypeScript with built-in exhaustiveness checks.
- library
Lint rule that catches non-exhaustive switches over union types.
- serviceTypeScript Playgroundfree tier
Hover the variable inside each branch to confirm the narrowed type.