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
// 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:
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
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
// 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.