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.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ 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.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ 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.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ 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 assertNever for 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

+50 XP on completion