DEV Community

Cover image for Your `switch` Is Lying to You: Discriminated Unions in TypeScript 6
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your `switch` Is Lying to You: Discriminated Unions in TypeScript 6


A reducer ships in a Tuesday PR. It has a switch over an action type, every case returns cleanly, the reviewers nod through it, and CI goes green on the first try.

Three weeks later, a different PR adds a new action variant. Nobody touches the reducer. Why would they. The reducer is correct in isolation, the new action is correct in isolation, the test for the new action passes, and you have a silent bug in production for nine days before a customer notices the cart total never updates after a refund.

There is no compile error. There is no warning. The checker walked the code, saw switch cases, saw return statements, let it through. The bug is in the old reducer that pretended to be exhaustive and was not.

If your reducers do not end in a call to assertNever, your "complete" switch is one merge away from this story.

The Bug Hiding in a "Complete" Switch

Here is the shape. A small action union, a reducer that handles every variant today.

type CartAction =
  | { type: "add"; sku: string; qty: number }
  | { type: "remove"; sku: string }
  | { type: "clear" };

type CartState = { lines: Record<string, number> };

function cart(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "add":
      return {
        lines: {
          ...state.lines,
          [action.sku]: (state.lines[action.sku] ?? 0) + action.qty,
        },
      };
    case "remove": {
      const next = { ...state.lines };
      delete next[action.sku];
      return { lines: next };
    }
    case "clear":
      return { lines: {} };
  }
}
Enter fullscreen mode Exit fullscreen mode

This compiles. The return type is CartState because every case returns one. Control-flow analysis is happy.

Now the requirement changes. A new action arrives.

type CartAction =
  | { type: "add"; sku: string; qty: number }
  | { type: "remove"; sku: string }
  | { type: "clear" }
  | { type: "refund"; sku: string; cents: number };
Enter fullscreen mode Exit fullscreen mode

The union grew. The reducer did not. What does TypeScript say about the reducer file?

Nothing. There is no error. Every existing case still returns CartState, so the function still compiles. The fall-off-the-end path now exists for refund, and without an explicit return annotation (or with noImplicitReturns off) the inferred return type quietly becomes CartState | undefined. If a caller does state = cart(state, action), the diagnostic (if any) shows up at the call site, far from the actual problem.

You shipped a silent bug. The "exhaustive" switch lied the moment the union grew, and nothing in the reducer file changed colour.

TypeScript 6.0 (released March 2026, the last release of the JavaScript-based compiler before the Go port lands) does not change this. Strict-by-default tightens a lot of footguns. The missing-case warning on a switch is not one of them. The check exists in typescript-eslint as an opt-in rule (@typescript-eslint/switch-exhaustiveness-check). If you want the compiler itself to scream, you have to ask it to.

Discriminants Are the Cheapest Type System You Will Ever Write

Discriminated unions are the part of TypeScript that pays for itself the fastest. The shape is one literal field, the same name on every variant, with a different string per variant.

type Loading = { status: "loading" };
type Success<T> = { status: "success"; data: T };
type Failure = { status: "failure"; error: Error };

type Async<T> = Loading | Success<T> | Failure;
Enter fullscreen mode Exit fullscreen mode

status is the discriminant. The narrowing rule the checker applies is direct: inside if (x.status === "success"), x is Success<T> and x.data is T. No casts. No generics on the call site. No instanceof. The compiler reads the literal types and walks the union.

People reach for instanceof in TypeScript because that is what they know from Java or C#. It works for class hierarchies and collapses the moment you cross a process boundary: JSON payloads have no prototype chain, queues serialise away the constructor, structuredClone returns plain objects. A string literal survives all of those because it is just data. That is why discriminant: string is the canonical TypeScript pattern and instanceof is the Java import.

function describe<T>(s: Async<T>): string {
  if (s.status === "loading") return "spinner";
  if (s.status === "success") return JSON.stringify(s.data);
  return s.error.message;
}
Enter fullscreen mode Exit fullscreen mode

The third branch needs no narrow. The first two narrowed away every other variant, so the residual must be Failure. The checker proved that for you. This is the entire trick. From here, assertNever is the seatbelt that catches the day you change the union and forget to update this function.

assertNever: the Three Lines That Stop Silent Bugs

The helper is small and you put it in a lib/ somewhere and you stop thinking about it.

// src/lib/exhaustive.ts
export function assertNever(value: never, message?: string): never {
  throw new Error(
    message ?? `unhandled discriminant: ${JSON.stringify(value)}`,
  );
}
Enter fullscreen mode Exit fullscreen mode

The signature does the work. The parameter type is never, which means the only value you can pass to it is one the checker has narrowed down to never. If you call it from a place where the checker still thinks the value could be a refund action, the call site fails to type-check. If the checker is correct that every other case has been handled, the call site is fine.

Wired into the cart reducer:

import { assertNever } from "./lib/exhaustive";

function cart(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "add":
      return {
        lines: {
          ...state.lines,
          [action.sku]: (state.lines[action.sku] ?? 0) + action.qty,
        },
      };
    case "remove": {
      const next = { ...state.lines };
      delete next[action.sku];
      return { lines: next };
    }
    case "clear":
      return { lines: {} };
    default:
      return assertNever(action);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now add the refund variant to the union. The reducer file goes red — tsc will print something close to:

src/cart.ts(24,24): error TS2345: Argument of type '{ type: "refund"; sku: string; cents: number; }' is not assignable to parameter of type 'never'.
Enter fullscreen mode Exit fullscreen mode

The error points at the exact line where the assumption broke. The fix is to add a case "refund". The seatbelt held. The bug never reached production.

The return in front of assertNever(action) matters less than people think. assertNever returns never, so the checker accepts it as the return value of any function. The throw inside is the runtime fallback for the case where the type system is bypassed at the boundary, e.g., a JSON payload arrives with a discriminant value the union does not declare. A loud crash there beats undefined riding into the next handler.

satisfies vs as for Building Discriminated Values

The other half of discriminated unions is constructing them. People reach for as because they want the literal type, not the widened string.

const a = { type: "add", sku: "abc", qty: 1 } as CartAction; // bad
Enter fullscreen mode Exit fullscreen mode

as is an unsafe coercion. It tells the checker "trust me, this fits." If you typo "add" to "ad", as CartAction accepts it, and the bug shows up at runtime when the reducer falls through. Using as here turns off the very type checking the union exists to provide.

satisfies is the operator you want.

const a = { type: "add", sku: "abc", qty: 1 } satisfies CartAction; // good
Enter fullscreen mode Exit fullscreen mode

satisfies checks that the expression is assignable to CartAction without widening the inferred type. The literal "add" stays a literal. If you typo to "ad", the check fails at the source, where the bug is. The variable's inferred type is the most specific shape the literal supports, so a.qty stays number instead of dissolving into the union shape.

Rule: use satisfies when constructing a value and you want both the literal type and the type-check. Use as only when you have proven the runtime cannot violate the assertion. For discriminated unions, as is almost always wrong.

satisfies shipped in TypeScript 4.9 and is mature. If your codebase still has as ActionType on action constructors, replace them. The diff is mechanical and the checker will tell you which as casts were lying.

ts-pattern: When the Helper Is Not Enough

switch plus assertNever covers maybe 80% of the discriminated-union work in a real codebase. The remaining 20% is the place where ts-pattern earns its weekly download count.

ts-pattern sits at roughly 4 million weekly npm downloads as of April 2026, up from around 900k in 2024 — the 5.x line coincided with broader adoption. That is not a fashion number. People reach for it because switch can't match on multiple discriminants at once, can't destructure inside the case, and forces assertNever boilerplate on every reducer.

import { match, P } from "ts-pattern";

type Event =
  | { type: "click"; button: "left" | "right"; target: string }
  | { type: "key"; key: string; mod: { shift: boolean; meta: boolean } }
  | { type: "scroll"; dy: number };

function handle(e: Event): string {
  return match(e)
    .with({ type: "click", button: "left" }, (c) => `left click on ${c.target}`)
    .with({ type: "click", button: "right" }, (c) => `context menu on ${c.target}`)
    .with({ type: "key", mod: { meta: true }, key: P.string }, (k) => `cmd-${k.key}`)
    .with({ type: "key" }, (k) => `key ${k.key}`)
    .with({ type: "scroll" }, (s) => (s.dy > 0 ? "down" : "up"))
    .exhaustive();
}
Enter fullscreen mode Exit fullscreen mode

exhaustive() is the feature that earns the dependency. It is assertNever enforced at the type level for arbitrary nested patterns, with full destructuring and wildcards. Remove the scroll clause and the call to exhaustive() fails to type-check, with an error pointing at the uncovered patterns. You do not write default:. You declare what you handle and the checker proves you handled all of it.

Reach for ts-pattern when the discriminant is multi-key, or when nested patterns would otherwise produce a tower of ifs — a state machine that reads clearer as patterns than as a switch is the same shape. Stay with switch plus assertNever when the discriminant is one string field and the cases are flat. Both coexist in the same codebase.

If you are already using Effect (latest stable 3.21 as of April 2026, v4 in beta), Match from effect/Match plays the same role inside that ecosystem and integrates with Effect's error channel.

TC39 Pattern Matching: Where the Wire Goes

If JavaScript shipped pattern matching, would you still need ts-pattern. Eventually, no. Today, yes.

The TC39 pattern matching proposal introduces a match expression, an is boolean operator, and a matcher-pattern DSL. It has been in the pipeline since 2018. As of 2026 it sits at Stage 1. Stage 1 means the committee agreed it is worth exploring; syntax is not settled, semantics are not settled, and engines are nowhere near implementing it. My read of TC39 cadence: native pattern matching in V8 and SpiderMonkey is probably a 2027–2028 conversation, with Node LTS lagging engines by another year.

This is why TypeScript still leans on never. The compiler cannot ship language constructs JavaScript does not have. What it can do is make the existing switch exhaustive at type-check time, via never and a helper you build on top. That is what the language designers chose, and it is what assertNever exploits.

When TC39 pattern matching reaches Stage 3 and engines start implementing, ts-pattern's API will likely converge with the standard, the way Array.prototype.includes made shim libraries redundant. Until then, the import is doing real work.

The next reducer you write, put assertNever in the default before you write any cases. Fill the cases until the file is green. The day someone adds a variant, your file goes red and the bug never gets shipped. That is the entire deal.


If this was useful

Discriminated unions are one of the patterns The TypeScript Type System spends real time on, because the type system's whole posture toward state and data flows through them. The book builds from literal-type machinery up through never, satisfies, branded types, and the conditional-type tricks that make libraries like ts-pattern possible to write at all. If you found assertNever useful and want to know why the checker treats never the way it does (and how to use that for more than reducers), that is the book.

If you are coming from JVM languages, the discriminated-union pattern is the TypeScript answer to Kotlin's sealed classes — Kotlin and Java to TypeScript makes that bridge. If you are coming from PHP 8+, PHP to TypeScript covers the same ground from the other side. If you are shipping TS at work, TypeScript in Production covers the build, monorepo, and dual-publish concerns the type system itself does not touch.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

The TypeScript Library — the 5-book collection

Top comments (0)