CodeStaminaOpen preview

Type narrowing · TypeScript · 8 min

Exhaustive switches: the default clause that hides new states

A default clause is a promise that you have thought about every future case. Almost nobody means to make that promise.

Train this

The problem

You have modelled payment outcomes as a discriminated union, and a switch handles each one. The switch ends with a default that returns a generic fallback message, because the compiler complained about a missing return.

Six months later somebody adds a fourth outcome: refunded. They update the union, run the test suite, and ship.

A switch with a fallback, after a fourth variant is added
type PaymentResult =
  | { status: 'paid'; receiptId: string }
  | { status: 'declined'; reason: string }
  | { status: 'pending' }
  | { status: 'refunded'; refundId: string };  // added today

function describe(result: PaymentResult): string {
  switch (result.status) {
    case 'paid': return `Receipt ${result.receiptId}`;
    case 'declined': return `Declined: ${result.reason}`;
    case 'pending': return 'Payment pending';
    default: return 'Unknown payment status';
  }
}

console.log(describe({ status: 'refunded', refundId: 'rf_1' }));

PREDICT FIRST

What does the compiler say about the newly added refunded variant?

Commit to an answer before you read on. Being wrong here is the part that sticks.

Why it behaves that way

Inside case "paid", TypeScript narrows result to the paid variant. By the time control reaches default, it has narrowed away every variant the switch names — so in the three-variant version, result there had type never.

That never is the useful signal, and the fallback throws it away. default: return "Unknown" accepts a value of any type, so it keeps compiling no matter how the union grows.

The fix is to make the default clause demand a never. A helper whose parameter is typed never can only be called where the compiler has proven nothing remains. Add a fourth variant, and the call site stops compiling with a message that names the variant you forgot.

Keep the throw inside the helper as well. Types describe the code you compiled; a value parsed from JSON at runtime can still carry a status nobody modelled.

A default clause that fails the build instead
function assertNever(value: never): never {
  throw new Error(`Unhandled payment status: ${JSON.stringify(value)}`);
}

function describe(result: PaymentResult): string {
  switch (result.status) {
    case 'paid': return `Receipt ${result.receiptId}`;
    case 'declined': return `Declined: ${result.reason}`;
    case 'pending': return 'Payment pending';
    // Argument of type '{ status: "refunded"; refundId: string; }'
    // is not assignable to parameter of type 'never'.
    default: return assertNever(result);
  }
}

What the change buys you

BehaviourBeforeAfter
Adding a variant to the unionCompiles; the new state hits the fallbackCompile error naming the unhandled variant
A refunded payment at runtimeRenders "Unknown payment status"Renders the case you were forced to write
An unmodelled status arriving from an APISilently rendered as unknownThrows with the offending value in the message
Deleting a variantDead case sits unnoticedUnreachable case flagged by the compiler

Variations worth trying

  1. Drop the default clause entirely and enable noImplicitReturns. You get exhaustiveness for functions with an annotated return type, but no error message that names the missing variant.
  2. Use the same trick on if/else if chains: the final else calls assertNever on the narrowed value.
  3. Apply it to string-literal state machines, Redux-style action unions, and parser node kinds — anywhere a closed set of cases is likely to grow.
  4. Try it on a union of about twenty variants and notice that the compile error points at the switch, not at the union. Add a comment on the union pointing back at every exhaustive consumer.

TRAIN IT

Repair a switch that forgets a state

Predict the output, choose between a fallback, an explicit case and an exhaustiveness check, then watch which of the four tests each one passes.

Open the workout

JOIN THE WAITLIST

Help shape what we build next.

Tell us where you want to start and what you want to practise. We’ll use your answers to understand demand and email you about relevant program availability.

What would you like to practise?

We store your email and answers for this waitlist. No campaign is being sent now. Contact mo@codestamina.com to ask us to remove your entry.