Discriminated Unions in TypeScript
Discriminated unions are the closest TypeScript gets to algebraic data types. Used correctly, they eliminate entire classes of runtime errors by making…
A deep exploration of discriminated unions in TypeScript — from basic tagged unions through recursive types, exhaustiveness checking, and integration with Zod for runtime validation. With production patterns for state machines, API responses, and event-driven systems.
Contents
There is a class of bugs that disappears entirely when you adopt discriminated unions. Not "becomes less likely" — disappears. The bug cannot be expressed. The type system rejects it before the code ever runs.
This is the promise of making illegal states unrepresentable. Discriminated unions are TypeScript's primary mechanism for fulfilling that promise.
The Problem They Solve
Consider a network request state. The naive representation looks like this:
interface RequestState {
loading: boolean
data: User | null
error: Error | null
}
This type has four possible states (loading×data×error), but only three are semantically valid: loading, success with data, failure with error. The fourth — loading: false, data: null, error: null — is the uninitialized state that you inevitably forget to handle. It causes blank screens, silent failures, and "why is this undefined?" bugs that are trivially reproducible but maddeningly hard to prevent.
With a discriminated union, the illegal state literally cannot be created:
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: Error }
The discriminant is the status field — the literal type that TypeScript uses to narrow the union. After a state.status === 'success' check, the compiler knows that state.data exists and is of type User. No null checks needed. No "TypeScript lies" comments needed. The type system carries the invariant.
Exhaustiveness Checking — The Killer Feature
Discriminated unions enable exhaustiveness checking, which means the TypeScript compiler will error if you add a new variant to a union without handling it everywhere that union is consumed.
function renderRequest(state: RequestState) {
switch (state.status) {
case 'idle': return <IdleState />
case 'loading': return <Spinner />
case 'success': return <UserCard user={state.data} />
case 'error': return <ErrorMessage error={state.error} />
default: {
// This assignment errors if any case is unhandled:
const _exhaustive: never = state
return null
}
}
}
When you add a new status — say { status: 'cancelled' } — the compiler reports an error at the const _exhaustive: never = state line. Every consumer of the union that uses this pattern will surface the gap. You cannot forget to handle a new variant.
This is the key property that makes discriminated unions valuable in large codebases: they propagate change requirements automatically. Add a variant, and TypeScript tells you everywhere you need to handle it.
Admin Action Unions — A Production Pattern
The pattern scales to complex admin interfaces with many action types. A well-modeled admin action union looks like this:
type AdminAction =
// Panel navigation
| { type: 'NAVIGATE'; payload: AdminPanel }
// Site configuration
| { type: 'UPDATE_SITE_CONFIG'; payload: Partial<SiteConfig> }
| { type: 'RESET_SITE_CONFIG' }
// Content
| { type: 'ADD_PROJECT'; payload: ProjectEntry }
| { type: 'UPDATE_PROJECT'; payload: { id: string; changes: Partial<ProjectEntry> } }
| { type: 'DELETE_PROJECT'; payload: string }
// Analytics
| { type: 'RECORD_PERF_SNAPSHOT'; payload: PerfSnapshot }
The reducer then exhaustively handles every case. When you need to add a new action, the compiler surfaces every reducer case and every dispatch site that needs to be updated. The union is self-documenting — the entire API surface of your state management is visible in one type declaration.
Zod Integration — Runtime Safety for Union Boundaries
Discriminated unions are a compile-time guarantee. At runtime, when data enters your system from localStorage, a URL parameter, or an API response, you need runtime validation. Zod's discriminatedUnion maps directly to TypeScript's discriminated union pattern:
import { z } from 'zod'
const RequestStateSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('idle') }),
z.object({ status: z.literal('loading') }),
z.object({ status: z.literal('success'), data: UserSchema }),
z.object({ status: z.literal('error'), error: z.string() }),
])
type RequestState = z.infer<typeof RequestStateSchema>
The type is derived from the schema, which means the runtime validator and the compile-time type are always in sync. Change the schema, the type changes. Change the type, the schema must change. They cannot diverge.
Recursive Discriminated Unions
Discriminated unions can be recursive, which enables modeling tree structures with full type safety:
type ContentNode =
| { kind: 'text'; content: string }
| { kind: 'emphasis'; children: ContentNode[] }
| { kind: 'heading'; level: 1 | 2 | 3; children: ContentNode[] }
| { kind: 'codeblock'; language: string; code: string }
function renderNode(node: ContentNode): string {
switch (node.kind) {
case 'text': return node.content
case 'emphasis': return `*${node.children.map(renderNode).join('')}*`
case 'heading': return `${'#'.repeat(node.level)} ${node.children.map(renderNode).join('')}`
case 'codeblock': return `\`\`\`${node.language}\n${node.code}\n\`\`\``
}
}
TypeScript correctly infers that node.content only exists on text nodes, node.children only exists on emphasis and heading nodes, and node.level only exists on heading nodes.
The Limitations
Discriminated unions have real limits. They require a single discriminant field to be present on every variant. If your data does not have a consistent discriminant field — for example, because you are working with a legacy API that uses different field names per variant — you need a different approach. Branded types, type predicates, or tagged wrappers can handle these cases.
The exhaustiveness pattern also requires discipline. The default: const _exhaustive: never = state pattern only works if the discriminant is exactly typed. If the discriminant is string rather than a union of literals, exhaustiveness checking is lost.
When to Use Them
Discriminated unions are the right tool when:
- You have a finite set of mutually exclusive states (request lifecycle, auth states, modal states)
- Each state carries different data (success has data, error has error, loading has neither)
- You want the compiler to enforce handling of all cases when you add new states
- The state is persisted or transmitted (pair with Zod for runtime validation)
They are not the right tool when:
- The states are not mutually exclusive (features that can be combined freely)
- The set of variants is truly open-ended and cannot be enumerated
- Performance is critical and the discriminant check adds meaningful overhead (rare)
Model your state correctly at the type level, and an entire category of bugs becomes impossible. That is the promise discriminated unions fulfill — and in production TypeScript codebases, they consistently deliver on it.
Integration with Zod
In practice, discriminated unions at the type level are most powerful when paired with runtime validation. Zod's z.discriminatedUnion mirrors TypeScript's structural pattern exactly — and critically, it validates that the discriminant field is present and matches one of the known literals before the data reaches your typed code.
import { z } from 'zod'
const RequestStateSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('idle') }),
z.object({ status: z.literal('loading') }),
z.object({ status: z.literal('success'), data: z.unknown() }),
z.object({ status: z.literal('error'), error: z.string() }),
])
type RequestState = z.infer
// Derived from the schema — single source of truth
This eliminates the common failure mode where a TypeScript type and its corresponding Zod schema drift out of sync over time. The type is derived from the schema, not maintained separately. When you add a new variant to the Zod schema, the TypeScript union updates automatically, and the compiler enforces exhaustive handling at every switch site.
The combination of discriminated unions and Zod is the most reliable way to handle typed, validated state across system boundaries in production TypeScript codebases.