Exhaustiveness: never as a Checklist
Advanced · 20 min read · ▶ live playground · ✦ checkpoint
Exhaustiveness means every member of a union has been handled. In TypeScript, never turns that promise into a compiler checklist: add a new variant, and the checker points at the places that forgot it.
You saw the basic const unhandled: never = value pattern in 4.3 and Project 1. This lesson hardens it into the version you use in real code: an assertNever helper, repeated affected sites, and exhaustive maps when a switch would only be a lookup table.
The assertNever helper
never is the empty set: no value can have that type. If a value reaches a line that accepts only never, TypeScript knows some union member escaped your checks.
The small helper is usually named assertNever. It accepts a never value and throws if runtime ever reaches it. The throw matters: if your JavaScript is called with impossible data anyway, the program fails loudly instead of returning a fake fallback.
→ Cart is empty
→ Cart has 3 item(s)
→ Receipt r-42
When every case is handled, the default branch sees state as never, so the helper call is allowed. Uncomment the "review" member and the helper call becomes the error site: state is no longer impossible there. The fix is not to change assertNever; the fix is to add the missing case "review".
This is better than a default string like "Unknown status". A fallback hides the missing case. assertNever makes the missing case a compile-time task.
One new variant, several precise errors
The professional payoff appears when the same union feeds more than one decision. A checkout event may need a user-facing label, an analytics metric, and a severity. If you add a new event, all three places must stay synchronized.
→ info checkout.cart_opened - Cart c-1 opened
→ info checkout.item_added - 2 x TS-101 added
→ warning checkout.payment_failed - Payment failed: card declined
Without assertNever, adding "coupon_applied" might compile while returning a vague fallback, logging the wrong metric, or choosing the wrong severity. With it, the affected sites fail immediately and locally.
Exhaustive maps for table-shaped decisions
A switch is best when each case runs different logic. If the decision is just "for every status, look up a label" or "for every status, look up a formatter," an exhaustive Record is often cleaner.
Record<Status, string> means every Status key must exist and every value must be a string. The key union becomes the checklist.
→ Draft [neutral] - Edit Welcome series
→ Scheduled [active] - Reschedule Launch reminder
→ Sent [success] - View report for Course update
Uncomment "paused" and the maps become the affected sites. That is exactly what you want: a new status needs a label, a tone, and an action. A plain object typed as Record<string, string> would not give you that checklist because "every string" is too wide to enumerate.
Use this version when the logic is data-shaped. If each case needs its own branching, validation, or side effects, use a switch with assertNever.
Keep the checklist close to the union
Exhaustiveness only helps if the union is the source of truth. Do not write a broad string status and then hope every status map stays complete. Keep the status as a literal union, or derive it from a trusted as const list when a runtime list is already the source of truth.
The rule is simple:
- Use a discriminated union plus
assertNeverfor variant-specific logic. - Use
Record<Union, Value>for variant-specific lookup tables. - Add new variants by changing the union first, then follow the checker errors.
Next lesson moves from impossible variants to missing values: strictNullChecks, optional chaining, nullish coalescing, and the habits that keep null from turning into surprise crashes.
Checkpoint
Answer all three to mark this lesson complete