Mesrai
Back to blog
// essayTechnical Deep Dive

Discriminated Union Missing default: An Exhaustive-Switch Catch

Real PR catch: switch over union missing a new variant. `never` exhaustiveness fix.

Mesrai TeamAugust 11, 20268 min read

A switch over a discriminated union of event types. Engineering added a new variant. The switch did not get updated. TypeScript did not warn — the default branch took the new event and returned silently.

The vulnerable diff

tsbilling/handle-event.ts
// billing/handle-event.ts
type BillingEvent =
  | { type: "invoice.created"; invoiceId: string }
  | { type: "invoice.updated"; invoiceId: string }
  | { type: "invoice.paid";    invoiceId: string }   // ← added recently
  | { type: "invoice.voided";  invoiceId: string };

function handle(event: BillingEvent) {
  switch (event.type) {
    case "invoice.created": return create(event.invoiceId);
    case "invoice.updated": return update(event.invoiceId);
    case "invoice.voided":  return voidInvoice(event.invoiceId);
    default: return;     // BUG: invoice.paid silently ignored
  }
}

What is wrong

TypeScript can enforce exhaustive switches via the `never` type. When the switch handles all variants, the value in the default branch narrows to `never` (no variant left). If a new variant is added later and not handled, the default-branch value no longer narrows to `never` — assigning it to a `never`-typed variable becomes a compile error. The standard pattern is to add `const _exhaustive: never = event;` in the default branch. Without it, the default branch silently absorbs new variants.

The attack

Symptom in production:

textexploit
1. New variant `invoice.paid` shipped 2 weeks ago.
2. Stripe webhooks now deliver `invoice.paid` events.
3. handle() falls into default branch, returns silently.
4. Invoices are never marked paid; reconciliation breaks.
5. Customer support tickets accumulate.

Hard to spot in code review — the switch looks complete because three cases are handled.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 1 min ago

[mesrai] [code-review] [Language] [Exhaustive-Switch] [medium]

Non-exhaustive switch over a discriminated union. New variant
`invoice.paid` is silently ignored by the default branch.

Add a `never`-assignment in default to make exhaustiveness a compile
error:

  function handle(event: BillingEvent) {
    switch (event.type) {
      case "invoice.created": return create(event.invoiceId);
      case "invoice.updated": return update(event.invoiceId);
      case "invoice.paid":    return markPaid(event.invoiceId);
      case "invoice.voided":  return voidInvoice(event.invoiceId);
      default:
        const _exhaustive: never = event;
        throw new Error(`unhandled event type: ${(event as never as any).type}`);
    }
  }

Now adding a new variant to BillingEvent without updating the switch
fails the build with "Type 'X' is not assignable to type 'never'".

The fix

tsbilling/handle-event.ts (fixed)
// billing/handle-event.ts — fixed
function handle(event: BillingEvent) {
  switch (event.type) {
    case "invoice.created": return create(event.invoiceId);
    case "invoice.updated": return update(event.invoiceId);
    case "invoice.paid":    return markPaid(event.invoiceId);
    case "invoice.voided":  return voidInvoice(event.invoiceId);
    default:
      const _exhaustive: never = event;
      throw new Error(`unhandled event type: ${(event as any).type}`);
  }
}

The `never` assignment forces exhaustiveness. Any new variant added later breaks the build at this site — exactly where the new variant needs to be handled.

Why human review missed it

Discriminated unions are a TypeScript strength but the exhaustiveness check is opt-in. The default switch behavior is permissive. Mesrai catches switches over discriminated unions without the `never` exhaustiveness pattern.

Related rules + further reading

Mesrai rule pack: language/exhaustive-switch — flags switches over discriminated unions without a never-assignment in default.

TypeScript handbook: Discriminated Unions § Exhaustiveness Checking.

Common bug class as union types grow over time.

Takeaway

Exhaustive switches via `never`. New variant = compile error at the switch. Mesrai catches every non-exhaustive switch over a union.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.