You added a state. You forgot a handler. Nothing warned you.

State machines start clean. Three values, three switch branches. Then retry logic adds a fourth state. Partial failure adds a fifth. You update the reducer, but miss the status badge component, the analytics mapper, and the export formatter.

Everything compiles. The bug surfaces two sprints later when a user hits a blank screen in a corner case you never manually tested.

TypeScript can make this impossible. Not with a linter rule. Not with a test. With the type system itself.

What exhaustiveness checking actually means

Exhaustiveness checking is the compiler proving you handled every variant of a type. Rust and OCaml do this by default. TypeScript has it too, but you have to ask for it.

The trick combines two things: discriminated unions, and a helper that accepts only the never type.

How discriminated unions model states

A discriminated union is a TypeScript type where every variant has a shared literal property, called the discriminant. For state machines, this is almost always a status, kind, or type field.

type State =
  | { status: "idle" }
  | { status: "loading"; requestId: string }
  | { status: "success"; data: string }
  | { status: "error"; message: string }

Each variant carries exactly the data relevant to that state. There is no optional data field on idle, no message field on success. The shape of the object tells you which state you are in, and the compiler narrows the type automatically inside a switch.

function handleState(state: State): string {
  switch (state.status) {
    case "idle":
      return "Waiting..."
    case "loading":
      return `Loading ${state.requestId}`
    case "success":
      return state.data
    case "error":
      return state.message
  }
}

Inside case "loading", the type is { status: "loading"; requestId: string }, so state.requestId is available. The compiler narrows automatically because the status literal is different for every variant.

This beats string enums and boolean flag soup. But it is not exhaustive yet. Remove the error case and TypeScript still compiles. The function implicitly returns undefined, and the compiler stays quiet.

The never trick that forces exhaustive handling

Here is the helper:

function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${JSON.stringify(value)}`)
}

This function accepts only never, the empty union, the type with no values. It is impossible to call assertNever with any real value unless you have lied to the compiler.

Now add it to the bottom of your switch:

function handleState(state: State): string {
  switch (state.status) {
    case "idle":
      return "Waiting..."
    case "loading":
      return `Loading ${state.requestId}`
    case "success":
      return state.data
    case "error":
      return state.message
    default:
      return assertNever(state)
  }
}

If every possible variant of State is handled above the default, then state inside the default branch has been narrowed to never. The call to assertNever(state) compiles.

If you add a new state and forget a case, the default branch now receives a real type. You cannot pass a real type to a never parameter. The compiler throws a type error.

Watching it fail in practice

Add a retrying state:

type State =
  | { status: "idle" }
  | { status: "loading"; requestId: string }
  | { status: "success"; data: string }
  | { status: "error"; message: string }
  | { status: "retrying"; attempt: number; lastError: string }

Now handleState fails to compile:

Argument of type '{ status: "retrying"; attempt: number; lastError: string; }'
is not assignable to parameter of type 'never'.

The error points directly at the default branch. Not a runtime crash. A compile-time refusal to build until you decide what retrying means in this specific function.

Each function that switches on State gets its own error. The compiler does not let you defer the work.

The pattern scales to any union type

This is not specific to state machines. Any discriminated union benefits from the same treatment.

Event handlers:

type Event =
  | { type: "USER_LOGIN"; userId: string }
  | { type: "USER_LOGOUT" }
  | { type: "PAGE_VIEW"; path: string }

function handleEvent(event: Event): void {
  switch (event.type) {
    case "USER_LOGIN":
      trackLogin(event.userId)
      return
    case "USER_LOGOUT":
      trackLogout()
      return
    case "PAGE_VIEW":
      trackPageView(event.path)
      return
    default:
      assertNever(event)
  }
}

Redux-style reducers:

type Action =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "reset" }

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "increment":
      return state + action.amount
    case "decrement":
      return state - action.amount
    case "reset":
      return 0
    default:
      return assertNever(action)
  }
}

Union of objects with a literal discriminant. Switch on it. Default to assertNever. Add a variant, and every switch explodes until you handle it.

Where this breaks down and what to do about it

Exhaustiveness checking is not magic. Know the sharp edges before you adopt it everywhere.

You must use a discriminated union

This does not work with plain string enums or boolean flags. The compiler needs a shared literal property to narrow on.

// This does NOT work for exhaustiveness checking
enum Status {
  Idle = "idle",
  Loading = "loading",
  Success = "success",
}

assertNever only proves exhaustiveness on closed unions. With enums, the compiler assumes any matching string is valid. It cannot prove you missed a case.

You must not use any or type assertions

If you cast through as State or pull from an any-typed response, the compiler skips narrowing. Validate at the edges, then trust the types inside.

assertNever throws at runtime as a last resort

The function is a compile-time guard first, runtime guard second. In theory you never hit the throw. In practice, a cast or stale JSON makes it your safety net. That is better than silently returning undefined.

It gets noisy with many branches

Adding an eleventh case triggers errors in every function that switches on the type. That is the point. But if your union grows past six or seven variants, consider splitting it into smaller sub-unions or separate machines.

How to introduce this without rewriting everything

Start with the state machine that causes the most bugs.

  1. Find a status field typed as string or a wide enum. Replace it with a closed union.
  2. Add assertNever to functions that switch on it.
  3. Let the compiler guide you.
  4. Add a code review checklist that requires assertNever in new reducers.

Even with XState, you will still write mappers and selectors that switch on state values. Those functions still need exhaustiveness checking.

FAQ

Does this work with if/else instead of switch?

Yes, but it is fragile. You need a final else that calls assertNever. Reorder the conditions or add an early return, and you can accidentally drop the check. switch is safer because the default branch is obvious.

What if I genuinely do not care about some states in a specific function?

Handle them explicitly. Do not let them fall through to assertNever by accident.

case "retrying":
  // No-op in this context
  return ""

The compiler forces you to make the decision visible. That is the point.

Can I return a value from assertNever?

A function returning never is assignable to any return type, because it never actually returns. If you want a fallback instead of throwing:

function assertNever(value: never, fallback: string): string {
  console.error("Unhandled state:", value)
  return fallback
}

The value: never parameter keeps the compile-time check intact.

Does this work in JavaScript?

No. This is a compile-time TypeScript feature. At runtime, assertNever is just a function that throws. The safety comes from the type checker refusing to build code that could reach it.

Make the compiler your second reviewer

Every time you add a state, you create work in the reducer, the UI, the analytics pipeline, the export logic. The default outcome is that you miss one of those places, ship, and find out later.

Exhaustiveness checking flips the default. The compiler becomes a reviewer that never forgets to check the switch statements. It does not replace tests, but it catches a category of bug that tests rarely cover: the one where you simply did not know you had to write code.

Add the helper. Use it in one reducer. The next time you extend a state machine, let TypeScript tell you where the work is. It will not miss a branch.