cheat sheet

Discriminated Unions

Discriminated unions model finite states with a shared literal tag, enabling exhaustive narrowing in switch statements, Result-style error handling, reducer actions, and pattern-matched API responses.

Discriminated Unions — Tagged Variants & Exhaustiveness

What it is

A discriminated union (also called a tagged union or algebraic data type) is a union of object types that share a single common property whose value is a literal — typically a string. TypeScript uses that literal "tag" to narrow the union to exactly one variant inside a branch, so every variant's payload becomes type-safe without casts. The pattern replaces class hierarchies for finite-state modelling and is the cornerstone of Redux/Zustand reducers, Result<T, E> error handling, and pattern-matched API responses.

Install

Discriminated unions are a pure language feature — no install is needed beyond TypeScript itself. The runtime counterpart with Zod is optional and shown below.

bash
# TypeScript itself
npm install -D typescript

# Optional: Zod for runtime discriminated unions
npm install zod

Output: (none — exits 0 on success)

Syntax

A discriminated union is written as | between object types that all share a literal-typed field. The literal field is the discriminant; the rest of the object is the payload.

typescript
type Variant =
  | { kind: "tagA"; payloadA: string }
  | { kind: "tagB"; payloadB: number };

Output: (none — exits 0 on success)

Essential parts

PartMeaning
Discriminant keyThe shared property name (kind, type, tag, status, _t). Pick one and use it everywhere in the codebase.
Literal tag valueThe string/number literal that identifies the variant ("add", "remove").
Variant payloadExtra fields that only make sense for that variant.
Narrowing siteA switch or if on the discriminant key.
Exhaustiveness checkA default branch that assigns the narrowed value to never.

Defining a discriminated union

The minimum viable union has two variants that share a literal-typed kind field. Use a type alias rather than an interface — interfaces cannot use the | operator directly, though they can each be a union member.

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rectangle"; width: number; height: number };

const c: Shape = { kind: "circle", radius: 5 };
const r: Shape = { kind: "rectangle", width: 4, height: 3 };

// Error: a Shape without a 'kind' is not assignable
// const bad: Shape = { radius: 5 };

Output: (none — exits 0 on success)

Use const assertions if you author the variants as runtime values first:

typescript
const circle = { kind: "circle", radius: 5 } as const;
type CircleVariant = typeof circle;
// { readonly kind: "circle"; readonly radius: 5 }

Output: (none — exits 0 on success)

Narrowing in a switch

The single most common consumption pattern is switch (value.kind). TypeScript narrows value inside each case to the matching variant, so accessing variant-only fields needs no cast.

typescript
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;     // shape: { kind: "circle"; radius: number }
    case "square":
      return shape.side ** 2;                 // shape: { kind: "square"; side: number }
    case "rectangle":
      return shape.width * shape.height;      // shape: { kind: "rectangle"; ... }
  }
}

console.log(area({ kind: "circle", radius: 5 }).toFixed(2));
console.log(area({ kind: "square", side: 4 }));
console.log(area({ kind: "rectangle", width: 3, height: 6 }));

Output:

code
78.54
16
18

If/else chains narrow exactly the same way — use switch when there are three or more variants and if for binary unions:

typescript
type Result =
  | { ok: true; value: string }
  | { ok: false; error: string };

function unwrap(r: Result): string {
  if (r.ok) {
    return r.value;       // r: { ok: true; value: string }
  }
  return `ERR: ${r.error}`; // r: { ok: false; error: string }
}

Output: (none — exits 0 on success)

Exhaustiveness checking with never

After narrowing every known variant, TypeScript infers the remaining type as never. Assigning a non-never value to a never-typed parameter is an error, which gives a compile-time guarantee that every variant is handled. The standard helper is assertNever:

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

function describe(shape: Shape): string {
  switch (shape.kind) {
    case "circle":    return `circle r=${shape.radius}`;
    case "square":    return `square s=${shape.side}`;
    case "rectangle": return `rect ${shape.width}x${shape.height}`;
    default:
      return assertNever(shape);
  }
}

console.log(describe({ kind: "circle", radius: 5 }));
console.log(describe({ kind: "square", side: 4 }));

Output:

ini
circle r=5
square s=4

Add | { kind: "triangle"; base: number; height: number } to Shape and TypeScript will refuse to compile the default branch — shape is no longer narrowed to never, so the call to assertNever(shape) becomes a type error. That compile-time fail is the whole reason the pattern exists.

Always reach for assertNever over a generic throw new Error("unreachable"). The generic throw runs at runtime; assertNever fails at compile time, which is what you actually want.

Result and Either patterns

Result<T, E> (or Either<L, R>) is a discriminated union that models either a successful value or a failure value. It is the type-system equivalent of returning errors instead of throwing them — every consumer has to handle both branches because they are distinct variants.

typescript
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

function parseNumber(s: string): Result<number, string> {
  const n = Number(s);
  return Number.isNaN(n) ? err(`not a number: ${s}`) : ok(n);
}

const r1 = parseNumber("42");
const r2 = parseNumber("abc");

for (const r of [r1, r2]) {
  if (r.ok) {
    console.log(`got ${r.value}`);
  } else {
    console.log(`failed: ${r.error}`);
  }
}

Output:

less
got 42
failed: not a number: abc

The advantage over throw is that the failure type is part of the function signature — a caller cannot forget to handle it. The disadvantage is that the variant access is verbose; libraries like neverthrow and effect provide combinators (map, andThen, mapErr) that compose Result values without manual unwrapping.

Discriminated tuples

A discriminated tuple uses position 0 as the tag instead of a named property. It is denser to read at the call site but harder to grep for and gives less helpful auto-complete — prefer object unions for non-trivial codebases.

typescript
type AsyncResult<T> =
  | [ok: true, value: T]
  | [ok: false, error: string];

function fetchUser(id: number): AsyncResult<{ name: string }> {
  if (id <= 0) return [false, "invalid id"];
  return [true, { name: "Alice Dev" }];
}

const result = fetchUser(1);
if (result[0]) {
  console.log(`user: ${result[1].name}`);
} else {
  console.log(`err: ${result[1]}`);
}

Output:

makefile
user: Alice Dev

Go-style destructuring works too, though TypeScript narrows the named fields only when you access them via the tuple itself (or via if (ok) then a separate value! access):

typescript
const [ok, payload] = fetchUser(1);
// ok: true | false, payload: { name: string } | string — NOT narrowed
// TypeScript loses correlation between tuple positions after destructuring

Output: (none — exits 0 on success)

This loss-of-correlation is the main reason most TypeScript style guides recommend object-tagged unions over tuple-tagged ones.

Redux-style reducer with exhaustive actions

The Redux pattern is essentially "every action is a discriminated union variant". The reducer narrows on action.type, the default branch asserts never, and adding a new action type causes a compile error in every reducer that fails to handle it.

typescript
type CounterState = { count: number };

type CounterAction =
  | { type: "increment"; by: number }
  | { type: "decrement"; by: number }
  | { type: "reset" }
  | { type: "set"; to: number };

function reducer(state: CounterState, action: CounterAction): CounterState {
  switch (action.type) {
    case "increment": return { count: state.count + action.by };
    case "decrement": return { count: state.count - action.by };
    case "reset":     return { count: 0 };
    case "set":       return { count: action.to };
    default:          return assertNever(action);
  }
}

let s: CounterState = { count: 0 };
s = reducer(s, { type: "increment", by: 5 });
s = reducer(s, { type: "increment", by: 3 });
s = reducer(s, { type: "decrement", by: 2 });
s = reducer(s, { type: "set", to: 100 });
console.log(s);

Output:

css
{ count: 100 }

The exhaustiveness check is what makes this scale. In a 40-action reducer the compiler tells you the moment you forget to handle one — no test required.

API response shapes

Discriminated unions model the common "loading / success / error" finite-state machine that every async UI eventually needs. Each variant carries exactly the data that branch needs and nothing else.

typescript
type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string; code: number };

function render(state: AsyncState<{ name: string }>): string {
  switch (state.status) {
    case "idle":    return "(not started)";
    case "loading": return "loading...";
    case "success": return `hello ${state.data.name}`;
    case "error":   return `[${state.code}] ${state.message}`;
    default:        return assertNever(state);
  }
}

console.log(render({ status: "idle" }));
console.log(render({ status: "loading" }));
console.log(render({ status: "success", data: { name: "Alice Dev" } }));
console.log(render({ status: "error", code: 500, message: "boom" }));

Output:

scss
(not started)
loading...
hello Alice Dev
[500] boom

The data field doesn't exist on the loading variant, so an over-eager state.data.name access in the wrong branch is a compile error. That eliminates an entire class of Cannot read property 'name' of undefined runtime bugs.

Discriminating on multiple fields

A union can be discriminated on more than one field — TypeScript narrows on whichever field you switch on, as long as all variants agree on that field's literal value. Multi-field discrimination is useful for state machines where transitions depend on a pair like (status, role).

typescript
type Session =
  | { status: "anonymous" }
  | { status: "authenticated"; role: "admin";  userId: string; permissions: string[] }
  | { status: "authenticated"; role: "viewer"; userId: string };

function canEdit(s: Session): boolean {
  if (s.status === "anonymous") return false;
  // s: authenticated variant (still admin | viewer)
  return s.role === "admin";
}

const admin: Session = {
  status: "authenticated",
  role: "admin",
  userId: "u_42",
  permissions: ["write", "delete"],
};
console.log(canEdit(admin));
console.log(canEdit({ status: "anonymous" }));

Output:

arduino
true
false

Pairing with as const for inference

When variants are declared as runtime values (e.g. an event creator), as const keeps the literal types narrow so the discriminated union still works. Without as const, TypeScript widens "increment" to string and the union collapses.

typescript
function increment(by: number) {
  return { type: "increment", by } as const;
}

function decrement(by: number) {
  return { type: "decrement", by } as const;
}

type Action = ReturnType<typeof increment> | ReturnType<typeof decrement>;
// { readonly type: "increment"; readonly by: number }
// | { readonly type: "decrement"; readonly by: number }

const a: Action = increment(5);
console.log(a);

Output:

bash
{ type: 'increment', by: 5 }

This is the most ergonomic way to author actions in modern Redux Toolkit / Zustand / TanStack Query mutations — the union type is derived from the creators, never written by hand.

Pattern matching with the never trick

You can also use the exhaustiveness check inline (without a helper function) — it is just const _: never = value at the bottom of an if/else chain. The helper is preferred because it produces a runtime error too, but the inline form is occasionally useful for compile-time-only checks.

typescript
function color(shape: Shape): string {
  if (shape.kind === "circle")    return "red";
  if (shape.kind === "square")    return "blue";
  if (shape.kind === "rectangle") return "green";
  const _exhaustive: never = shape; // compile error if Shape grows
  return _exhaustive;
}

console.log(color({ kind: "circle", radius: 5 }));

Output:

code
red

Runtime validation with Zod

Zod's z.discriminatedUnion() constructor parses a discriminated union at runtime — useful for validating JSON from an API where you cannot trust the shape. It is significantly faster than z.union() for tagged unions because Zod only validates the matching variant instead of trying every one.

typescript
import { z } from "zod";

const ActionSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("increment"), by: z.number() }),
  z.object({ type: z.literal("decrement"), by: z.number() }),
  z.object({ type: z.literal("reset") }),
]);

type Action = z.infer<typeof ActionSchema>;
//   | { type: "increment"; by: number }
//   | { type: "decrement"; by: number }
//   | { type: "reset" }

const parsed = ActionSchema.parse({ type: "increment", by: 5 });
console.log(parsed);

const result = ActionSchema.safeParse({ type: "wat" });
if (!result.success) {
  console.log("invalid");
}

Output:

bash
{ type: 'increment', by: 5 }
invalid

z.infer gives you the TypeScript discriminated union "for free" from the runtime schema, so you avoid the duplication that plagues hand-written guards.

Discriminated unions vs class hierarchies

Class hierarchies model the same finite-state idea as discriminated unions but use a runtime instanceof check instead of a literal tag. They are familiar to OOP developers but trade off in several places that matter for TypeScript:

AspectDiscriminated unionClass hierarchy
SerializationJSON-friendly (plain objects)Loses class identity after JSON.parse
ExhaustivenessAutomatic via neverManual else throw
Add a variantAdd to type alias — compiler finds all sitesAdd a subclass — search every instanceof chain
Method dispatchExternal functionsPolymorphic methods on the class
Memory shapePlain objectHidden prototype, ctor
Pattern matchingNative via switch on tagVerbose instanceof chains

Use a class only when you need (a) polymorphic methods bound to the data, or (b) a private/# field for true encapsulation. Use a discriminated union for everything else, especially anything that crosses the wire.

Common pitfalls

  1. Discriminant is not a literal type — if the field is typed string (not "add"), narrowing fails. Use as const, an enum, or a literal-string property annotation.
  2. Forgetting assertNever — without the exhaustiveness check, adding a new variant later silently skips the new branch. The fix is one line; always include it.
  3. Different discriminant key per variant{ kind: "a" } | { type: "b" } is not a discriminated union. Pick one key and use it everywhere.
  4. Destructuring loses correlationconst { kind, payload } = action separates the discriminant from the payload and TypeScript drops the narrowing relationship. Access fields through the original variable inside each branch.
  5. Non-string discriminants without as constkind: 1 will widen to number unless annotated with a literal type or const-asserted.
  6. Using instanceof with classes that also have a kind field — pick one discrimination mechanism. Mixing both confuses readers and the compiler.
  7. Optional discriminant{ kind?: "a"; ... } doesn't narrow because the field can be absent. Make the discriminant required in every variant.
  8. Catch-all variant swallows new cases — a default: return defaultValue instead of assertNever defeats the purpose. Only use a default branch when you genuinely want a fallback, and follow it with an assertNever in dev mode.

Real-world recipes

Recipe 1: typed Redux Toolkit-style slice

A self-contained counter slice that exposes typed action creators and a fully-exhaustive reducer. The action union is derived from the creators via ReturnType, so adding a new creator automatically extends the union.

typescript
const increment = (by: number) => ({ type: "increment", by }) as const;
const decrement = (by: number) => ({ type: "decrement", by }) as const;
const reset     = ()             => ({ type: "reset"     }) as const;

type Action = ReturnType<typeof increment | typeof decrement | typeof reset>;
type State  = { count: number };

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

let s: State = { count: 0 };
s = reducer(s, increment(10));
s = reducer(s, increment(5));
s = reducer(s, decrement(3));
console.log(s);
s = reducer(s, reset());
console.log(s);

Output:

css
{ count: 12 }
{ count: 0 }

Recipe 2: pattern-matched fetch with Result

A network helper that always returns Result<T, ApiError> instead of throwing. Callers must if (res.ok) to access the success payload, which makes error paths impossible to forget.

typescript
type ApiError =
  | { kind: "network"; cause: unknown }
  | { kind: "http"; status: number; body: string }
  | { kind: "parse"; message: string };

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

async function getJson<T>(url: string): Promise<Result<T, ApiError>> {
  let res: Response;
  try {
    res = await fetch(url);
  } catch (cause) {
    return { ok: false, error: { kind: "network", cause } };
  }
  if (!res.ok) {
    return {
      ok: false,
      error: { kind: "http", status: res.status, body: await res.text() },
    };
  }
  try {
    return { ok: true, value: (await res.json()) as T };
  } catch (e) {
    return { ok: false, error: { kind: "parse", message: String(e) } };
  }
}

const r = await getJson<{ name: string }>("https://example.com/api/user");
if (r.ok) {
  console.log(`hello ${r.value.name}`);
} else {
  switch (r.error.kind) {
    case "network": console.log("network down");                break;
    case "http":    console.log(`http ${r.error.status}`);      break;
    case "parse":   console.log(`bad json: ${r.error.message}`); break;
    default:        assertNever(r.error);
  }
}

Output:

code
network down

Recipe 3: form field state machine

Form inputs naturally form a state machine: pristine -> dirty -> validating -> valid | invalid. A discriminated union lets the rendering layer access exactly the fields each state needs and nothing else.

typescript
type FieldState<T> =
  | { status: "pristine"; value: T }
  | { status: "dirty";    value: T; touched: true }
  | { status: "validating"; value: T }
  | { status: "valid";    value: T }
  | { status: "invalid";  value: T; errors: string[] };

function label<T>(field: FieldState<T>): string {
  switch (field.status) {
    case "pristine":   return "(untouched)";
    case "dirty":      return "(edited)";
    case "validating": return "checking...";
    case "valid":      return "OK";
    case "invalid":    return `error: ${field.errors.join(", ")}`;
    default:           return assertNever(field);
  }
}

console.log(label({ status: "pristine", value: "" }));
console.log(label({ status: "invalid", value: "x", errors: ["too short", "no @"] }));

Output:

vbnet
(untouched)
error: too short, no @

Recipe 4: WebSocket message router

A type-tagged WebSocket protocol where each message variant has its own payload. The router narrows once on msg.type and dispatches to handlers that already know their payload shape.

typescript
type Msg =
  | { type: "ping"; ts: number }
  | { type: "chat"; from: string; text: string }
  | { type: "presence"; userId: string; online: boolean }
  | { type: "error"; code: number; message: string };

function route(msg: Msg): void {
  switch (msg.type) {
    case "ping":
      console.log(`pong ${msg.ts}`);
      break;
    case "chat":
      console.log(`${msg.from}: ${msg.text}`);
      break;
    case "presence":
      console.log(`${msg.userId} ${msg.online ? "online" : "offline"}`);
      break;
    case "error":
      console.log(`error ${msg.code}: ${msg.message}`);
      break;
    default:
      assertNever(msg);
  }
}

route({ type: "ping", ts: 1700000000 });
route({ type: "chat", from: "Alice Dev", text: "hello" });
route({ type: "presence", userId: "u_1", online: true });

Output:

yaml
pong 1700000000
Alice Dev: hello
u_1 online

Recipe 5: pipe / fold helper

A reusable "fold" helper that turns a discriminated union into a value by taking one handler per variant. The mapped-type signature ensures you provide a handler for every variant — adding a variant breaks the call site at compile time.

typescript
type Match<U extends { kind: string }, R> = {
  [K in U["kind"]]: (variant: Extract<U, { kind: K }>) => R;
};

function match<U extends { kind: string }, R>(value: U, cases: Match<U, R>): R {
  return cases[value.kind as U["kind"]](value as Extract<U, { kind: U["kind"] }>);
}

const area2 = (s: Shape) =>
  match(s, {
    circle:    (c) => Math.PI * c.radius ** 2,
    square:    (s) => s.side ** 2,
    rectangle: (r) => r.width * r.height,
  });

console.log(area2({ kind: "circle", radius: 2 }).toFixed(2));
console.log(area2({ kind: "square", side: 3 }));
console.log(area2({ kind: "rectangle", width: 4, height: 5 }));

Output:

code
12.57
9
20