Definition
Enforcing exhaustiveness means making the compiler check, on your behalf, that you handled every case in a union.
The goal is that when you add a new member to a union, every spot that has to handle it lights up in red. So you don't have to remember and hunt them down yourself.
Why you need it
Adding one case to a union is an extremely common task. Say you add 'refunded' to an order status.
type OrderStatus = "pending" | "paid" | "shipped" | "refunded"; // ← added
function statusLabel(status: OrderStatus): string {
switch (status) {
case "pending": return "Awaiting payment";
case "paid": return "Payment complete";
case "shipped": return "Shipping";
default: return "Unknown"; // ← it quietly falls through here
}
}
It compiles. And the screen shows "Unknown". If there are five more places besides this function that need to handle the new status, all five quietly do the wrong thing too.
The default branch is the culprit. A catch-all you added for convenience hides what you missed.
Enforce exhaustiveness and this task changes completely. The moment you add one line to the union, every place that needs handling is listed for you as a compile error. The compiler builds your fix list.
How it works
There are three layers of tooling. Use the ones with no dependencies first, and only bring in a library when the conditions call for it.
Layer 1 — assertNever (always available)
The mechanism is the never type. Once you've handled every case, TypeScript narrows the remaining value's type to never. If a non-never value is left over, the assignment fails.
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function statusLabel(status: OrderStatus): string {
switch (status) {
case "pending": return "Awaiting payment";
case "paid": return "Payment complete";
case "shipped": return "Shipping";
default: return assertNever(status);
// ^ 'refunded' is not assignable to 'never'
}
}
If you don't handle refunded, status arrives at assertNever narrowed to 'refunded', and compilation fails. You're not removing the catch-all — you're turning the catch-all into a compile error.
Reuse assertNever if your repo already has one; if not, create it in exactly one shared location.
Layer 2 — satisfies Record (when the result is a static value)
If all each case does is "pick a value", a lookup object beats a switch.
const STATUS_LABEL = {
pending: "Awaiting payment",
paid: "Payment complete",
shipped: "Shipping",
refunded: "Refunded",
} satisfies Record<OrderStatus, string>;
const label = STATUS_LABEL[status];
satisfies rejects both missing keys and extra keys, while preserving literal types as-is. With an annotation (: Record<OrderStatus, string>) the value widens and STATUS_LABEL.pending becomes string, but satisfies keeps "Awaiting payment".
This works just as well for render functions, permission maps, and message maps — not just labels.
const STATUS_ICON = {
pending: ClockIcon,
paid: CheckIcon,
shipped: TruckIcon,
refunded: RefundIcon,
} satisfies Record<OrderStatus, ComponentType>;
Be careful about what you use as the key. Use the union you're branching on itself.
// A literal union goes in directly
satisfies Record<OrderStatus, string>
// For a tagged object union, pull the tag out with an indexed access
satisfies Record<CheckoutState["status"], string>
Layer 3 — pattern matching libraries (only when one is already installed)
.exhaustive() from a library like ts-pattern checks exhaustiveness even across nested conditions.
match(mutation)
.with({ status: "error", error: { code: "CONFLICT" } }, () => showConflict())
.with({ status: "error" }, () => showGenericError())
.with({ status: "success" }, () => close())
.with({ status: "pending" }, () => showSpinner())
.exhaustive();
When you have to branch on nested fields, layers 1 and 2 get messy to express. That's the only time this pays off. Decide first whether the problem is worth a new dependency — and if one is already installed, use it freely.
Applying it in practice
Don't create a tagged object just because you want a label map
This is a common misjudgment.
// ❌ Wrapped a literal union just to use satisfies Record
type PaymentBadge = { kind: "unpaid" } | { kind: "paid" } | { kind: "refunded" };
// ✅ satisfies Record applies to a literal union directly
type PaymentBadge = "unpaid" | "paid" | "refunded";
const BADGE_LABEL = { unpaid: "Unpaid", paid: "Paid", refunded: "Refunded" }
satisfies Record<PaymentBadge, string>;
Tagged objects are only for when two or more members carry their own fields. Wrapping just adds the cost of unwrapping .kind at every call site.
The order to pick a layer in
| Situation | Layer |
|---|---|
| Logic differs per case, with early returns mixed in | Layer 1 (assertNever) |
| Each case only picks a static value or component | Layer 2 (satisfies Record) |
| You must branch on combinations of nested fields + the library is already there | Layer 3 (pattern matching) |
The flow when adding a new case
With exhaustiveness properly wired up, the work goes like this.
- Add a member to the union.
- Run the type check.
- The compiler prints your fix list.
- Work through the list from the top.
- When the list is empty, you're done.
Without it, step 3 turns into "grep the codebase and find them yourself".
Trade-offs
It costs a little more effort up front. You have to create the assertNever helper, and a lookup object can feel unfamiliar next to a switch.
What you get is safety at change time. And that cost/benefit ratio improves the longer the union lives. For a union you use once and throw away, don't bother; for a union that keeps growing, like domain state, you absolutely should.
There's one more subtle cost. Enforcing exhaustiveness turns adding to a union into a "big change". Because five places go red. That's actually an accurate signal — it was always a change that required fixing five places. But if the team reads that signal as "annoying", pressure builds to put the default branch back.
When not to use it
- Open sets that consumers extend. Sets that consumers add to — plugin keys, app-specific event names — were never closed unions to begin with. That's the territory of a typed registry or module augmentation, not exhaustiveness.
- When a default behavior really is defined. If "unknown states get a gray badge" is specified as a requirement, a catch-all is correct. Even then, write that policy down explicitly instead of using
assertNever, so it reads differently from a reflexivedefault. - Short unions used inside a single file. When the usage sites are right next door, you can see them without the compiler's help.
Common mistakes
- Writing
default: return 'Unknown'. One line added for convenience swallows every future omission. - Creating
assertNeverbut not putting it indefault. A helper you never call does nothing. - Using an annotation instead of
satisfies.const X: Record<K, string> = {...}catches missing keys but widens literal types. - Picking the wrong key for
Record. Don't useRecord<State, ...>on a tagged object union. You needRecord<State['status'], ...>to pull the tag out. - Trying to handle impossible combinations too. If the union was designed badly in the first place so that impossible states are representable, fix the union before enforcing exhaustiveness.
- Wrapping a literal union into a tagged object just to use a label map.
satisfies Recordapplies to a literal union directly.
Related concepts
- state-modeling-ladder — deciding whether you even need the union you'd enforce exhaustiveness on
- false-type-contracts — not mistaking
satisfiesfor runtime validation - type-level-testing — checking that tests actually fail when you widen a union