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.
# 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.
type Variant =
| { kind: "tagA"; payloadA: string }
| { kind: "tagB"; payloadB: number };
Output: (none — exits 0 on success)
Essential parts
| Part | Meaning |
|---|---|
| Discriminant key | The shared property name (kind, type, tag, status, _t). Pick one and use it everywhere in the codebase. |
| Literal tag value | The string/number literal that identifies the variant ("add", "remove"). |
| Variant payload | Extra fields that only make sense for that variant. |
| Narrowing site | A switch or if on the discriminant key. |
| Exhaustiveness check | A 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.
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:
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.
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:
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:
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:
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:
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
assertNeverover a genericthrow new Error("unreachable"). The generic throw runs at runtime;assertNeverfails 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.
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:
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.
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:
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):
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.
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:
{ 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.
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:
(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).
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:
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.
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:
{ 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.
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:
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.
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:
{ 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:
| Aspect | Discriminated union | Class hierarchy |
|---|---|---|
| Serialization | JSON-friendly (plain objects) | Loses class identity after JSON.parse |
| Exhaustiveness | Automatic via never | Manual else throw |
| Add a variant | Add to type alias — compiler finds all sites | Add a subclass — search every instanceof chain |
| Method dispatch | External functions | Polymorphic methods on the class |
| Memory shape | Plain object | Hidden prototype, ctor |
| Pattern matching | Native via switch on tag | Verbose 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
- Discriminant is not a literal type — if the field is typed
string(not"add"), narrowing fails. Useas const, an enum, or a literal-string property annotation. - Forgetting
assertNever— without the exhaustiveness check, adding a new variant later silently skips the new branch. The fix is one line; always include it. - Different discriminant key per variant —
{ kind: "a" } | { type: "b" }is not a discriminated union. Pick one key and use it everywhere. - Destructuring loses correlation —
const { kind, payload } = actionseparates the discriminant from the payload and TypeScript drops the narrowing relationship. Access fields through the original variable inside each branch. - Non-string discriminants without
as const—kind: 1will widen tonumberunless annotated with a literal type or const-asserted. - Using
instanceofwith classes that also have akindfield — pick one discrimination mechanism. Mixing both confuses readers and the compiler. - Optional discriminant —
{ kind?: "a"; ... }doesn't narrow because the field can be absent. Make the discriminant required in every variant. - Catch-all variant swallows new cases — a
default: return defaultValueinstead ofassertNeverdefeats the purpose. Only use a default branch when you genuinely want a fallback, and follow it with anassertNeverin 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.
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:
{ 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.
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:
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.
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:
(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.
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:
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.
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:
12.57
9
20