Discriminated unions are one of the most practical advanced TypeScript patterns because they turn vague object shapes into models the compiler can reason about. Used well, they make UI state safer, event handling clearer, and API contracts easier to evolve. This guide gives you a reusable mental model and a set of patterns you can carry across React components, Next.js routes, Node.js services, and shared domain libraries. If you already know basic unions, this article shows how to structure them so they stay readable as a codebase grows.
Overview
This section gives you the core idea: what discriminated unions are, why they work, and where they fit best.
A discriminated union is a union of object types that all share one common field with a distinct literal value. That common field is the discriminator. TypeScript uses it to narrow the union safely.
type LoadingState = { status: 'loading' }
type SuccessState = { status: 'success'; data: string[] }
type ErrorState = { status: 'error'; message: string }
type State = LoadingState | SuccessState | ErrorState
function render(state: State) {
switch (state.status) {
case 'loading':
return 'Loading...'
case 'success':
return state.data.join(', ')
case 'error':
return state.message
}
}In this example, status is the discriminator. Once the code checks state.status === 'success', TypeScript knows state has a data property. That is the practical benefit: less guessing, fewer optional properties, and fewer invalid object combinations.
Without a discriminated union, teams often model objects like this:
type BadState = {
isLoading?: boolean
data?: string[]
error?: string
}This shape is flexible in the wrong way. It allows impossible combinations, like { isLoading: true, data: ['x'], error: 'failed' }. The compiler cannot tell which combinations are valid because the model does not express real states.
Discriminated unions are especially useful when:
- You have a small set of mutually exclusive states.
- You process events with different payloads.
- You need a stable shape for API success and error responses.
- You want exhaustive checks so new cases cannot be ignored accidentally.
They are less useful when all variants share nearly the same fields and differ only in one optional value. In that case, a simpler object type may be enough.
If you are earlier in your TypeScript journey, pair this guide with TypeScript for Beginners: A Step-by-Step Learning Path That Stays Current. If you want a related pattern for keeping object literals precise, see How to Use satisfies in TypeScript with Safer Object and Config Patterns.
Template structure
This section provides a reusable structure you can adapt for state, events, and API modeling.
The basic template has four parts:
- A discriminator field with literal values such as
type,kind, orstatus. - One object type per valid variant.
- A union that combines those variants.
- A narrowing point, usually a
switchstatement or conditional branch.
1. Choose a stable discriminator
Use a field name that communicates intent clearly:
statusfor async or lifecycle statetypefor domain events or actionskindfor structural variantsresultfor success or failure outcomes
Avoid switching discriminator names across similar models. If one package uses type and another uses kind for the same concept, the cost is cognitive, not technical, but it adds up.
2. Keep common fields truly common
If every variant shares a field, move it into a base type. If only some variants use it, keep it local to those variants.
type BaseEvent = {
timestamp: number
}
type UserCreated = BaseEvent & {
type: 'user.created'
userId: string
email: string
}
type UserDeleted = BaseEvent & {
type: 'user.deleted'
userId: string
reason: 'requested' | 'fraud' | 'inactive'
}
type UserEvent = UserCreated | UserDeletedThis keeps shared concerns visible without forcing unrelated fields into every branch.
3. Prefer exact variants over broad optional fields
Each variant should describe one valid shape. Resist the urge to make one large type with many optional properties. Optional-heavy models are often a sign that a union should exist.
A practical test is simple: can you describe each branch in one sentence? If yes, it is probably a good fit for a discriminated union.
4. Use exhaustive checking
The most valuable habit with discriminated unions is exhaustive handling. When a new variant is added, TypeScript should guide you to every place that must change.
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}
function handleEvent(event: UserEvent) {
switch (event.type) {
case 'user.created':
return `Created ${event.email}`
case 'user.deleted':
return `Deleted ${event.userId}`
default:
return assertNever(event)
}
}The assertNever pattern turns a missed case into a compile-time signal. This is one of the strongest reasons to adopt discriminated unions in shared code.
5. Derive helpers when the union grows
As unions expand, utility types help keep code maintainable.
type EventOfType<T extends UserEvent['type']> = Extract<UserEvent, { type: T }>
type UserCreatedEvent = EventOfType<'user.created'>Extract is particularly useful for event buses, reducers, and handler registries.
How to customize
This section shows how to shape the pattern for real projects instead of leaving it as a textbook example.
Modeling UI state
Discriminated unions are a strong fit for UI state because screens usually have a finite set of valid states. A common mistake in React is to model async state with separate booleans like loading, error, and data. That makes invalid combinations easy.
A better model:
type UserState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; user: { id: string; name: string } }
| { status: 'error'; message: string }This gives components a cleaner rendering story:
function UserPanel({ state }: { state: UserState }) {
switch (state.status) {
case 'idle':
return <p>Start a search</p>
case 'loading':
return <p>Loading...</p>
case 'success':
return <h2>{state.user.name}</h2>
case 'error':
return <p>{state.message}</p>
default:
return null
}
}For broader React guidance, see React with TypeScript: Best Practices for Props, Hooks, and Component APIs.
Modeling reducer actions and events
Action objects are naturally discriminated unions. Each action has a type and a payload that depends on that type.
type Action =
| { type: 'add-todo'; text: string }
| { type: 'toggle-todo'; id: string }
| { type: 'clear-completed' }
function reducer(state: string[], action: Action): string[] {
switch (action.type) {
case 'add-todo':
return [...state, action.text]
case 'toggle-todo':
return state
case 'clear-completed':
return state
default:
return state
}
}This pattern scales well into domain events, analytics events, and message queue payloads. If the event list becomes large, organize variants by domain module and compose unions from smaller groups.
Modeling API responses
For API modeling, discriminated unions help represent success and failure explicitly. This is often easier to maintain than returning a partially optional object.
type ApiResponse<T> =
| { result: 'ok'; data: T }
| { result: 'error'; error: { code: string; message: string } }
async function getUser(): Promise<ApiResponse<{ id: string; name: string }>> {
return { result: 'ok', data: { id: '1', name: 'Ada' } }
}Consumers can then narrow on result with no ambiguity. This works especially well in fetch clients, service layers, and route handlers. For adjacent API typing guidance, read How to Type API Responses in TypeScript for REST, GraphQL, and Fetch Clients.
If your input comes from the network, remember that TypeScript types do not validate runtime data. Pair unions with runtime validation when data crosses trust boundaries. A helpful comparison is Zod vs Yup vs Valibot: Runtime Validation Libraries for TypeScript Compared.
Using maps for scalable handler registration
As event unions grow, a switch statement may become noisy. One alternative is a handler map keyed by the discriminator. This can be made type-safe with Extract.
type EventHandlerMap = {
[K in UserEvent['type']]: (event: Extract<UserEvent, { type: K }>) => void
}
const handlers: EventHandlerMap = {
'user.created': event => {
console.log(event.email)
},
'user.deleted': event => {
console.log(event.reason)
}
}This style is useful for event-driven Node.js code and shared libraries, especially when teams want registration to be declarative.
Practical naming and design rules
- Use literal string values that are stable and descriptive.
- Prefer one clear discriminator over several overlapping flags.
- Keep payload fields required inside each variant unless they are genuinely optional.
- Avoid unions where branches differ only by one nullable field; that usually adds noise without much safety.
- Group variants by domain, not by file convenience alone.
If your codebase spans multiple packages, consistency matters even more. Shared domain types and project boundaries are easier to maintain with a predictable modeling style. Related reading: TypeScript Monorepo Guide: Project References, Path Aliases, and Package Boundaries.
Examples
This section turns the pattern into reusable examples teams often revisit.
Example 1: Request lifecycle as a state machine
type RequestState<T> =
| { status: 'idle' }
| { status: 'pending' }
| { status: 'success'; data: T }
| { status: 'failure'; error: string }
function toMessage<T>(state: RequestState<T>): string {
switch (state.status) {
case 'idle':
return 'Not started'
case 'pending':
return 'Loading'
case 'success':
return 'Done'
case 'failure':
return state.error
}
}This is a simple but durable pattern for React components, server actions, and form workflows. In Next.js applications, this can help make client and server state transitions more explicit; see Next.js with TypeScript: App Router Patterns, Server Actions, and Type Safety.
Example 2: Payment event union
type PaymentEvent =
| { type: 'payment.authorized'; paymentId: string; amount: number }
| { type: 'payment.captured'; paymentId: string; capturedAt: string }
| { type: 'payment.failed'; paymentId: string; reason: string }
function audit(event: PaymentEvent) {
switch (event.type) {
case 'payment.authorized':
return `${event.paymentId} authorized for ${event.amount}`
case 'payment.captured':
return `${event.paymentId} captured at ${event.capturedAt}`
case 'payment.failed':
return `${event.paymentId} failed: ${event.reason}`
default:
return assertNever(event)
}
}Event unions work particularly well for logs, integrations, and messaging code because the discriminator doubles as a stable business identifier.
Example 3: API command result
type SaveUserResult =
| { result: 'saved'; userId: string }
| { result: 'validation-error'; fieldErrors: Record<string, string> }
| { result: 'conflict'; message: string }
function handleSaveResult(result: SaveUserResult) {
switch (result.result) {
case 'saved':
return `Saved ${result.userId}`
case 'validation-error':
return Object.values(result.fieldErrors).join(', ')
case 'conflict':
return result.message
default:
return assertNever(result)
}
}This is often more useful than a generic { success: boolean } shape because it preserves domain meaning.
Example 4: Safer configuration objects
Discriminated unions are not only for runtime state. They can also model valid config combinations.
type AuthConfig =
| { mode: 'anonymous' }
| { mode: 'jwt'; secret: string }
| { mode: 'oauth'; clientId: string; clientSecret: string }Instead of optional fields like secret? and clientId?, the union encodes which properties belong to each mode. This pairs nicely with the satisfies operator when authoring config objects.
Example 5: Database operation intent
Even infrastructure code benefits from explicit modeling.
type UserWriteOperation =
| { kind: 'insert'; user: { email: string; name: string } }
| { kind: 'update'; userId: string; patch: Partial<{ email: string; name: string }> }
| { kind: 'delete'; userId: string }This does not replace your ORM types, but it gives your application layer a cleaner contract before persistence details take over. For tool selection around this layer, see Best TypeScript ORM and Query Builder Tools Compared.
Common pitfalls to avoid
- Using non-literal discriminators:
type: stringloses narrowing power. Use literal unions like'created' | 'deleted'. - Overloading one variant: if a branch has many internal sub-cases, it may need its own nested union.
- Leaking transport shapes into domain models: API responses and domain entities often deserve separate unions.
- Skipping runtime validation: compile-time safety does not guarantee external data matches your union.
- Ignoring tooling: lint rules and project organization help keep patterns consistent. For setup guidance, see ESLint and TypeScript Setup Guide: Flat Config, Rules, and Performance Tips and Node.js with TypeScript: Project Structure, ESM vs CJS, and Build Setup.
When to update
This section helps you decide when your union design should be revisited and what to change first.
Discriminated unions age well, but they should be reviewed when the underlying domain changes. Revisit a union when:
- A new business state appears, such as a new payment status or moderation outcome.
- A single variant starts accumulating too many optional fields.
- The same discriminator values appear in multiple places with slight differences.
- Handlers are no longer exhaustive or teams are using unsafe casts to work around errors.
- External API responses have changed and your runtime validation no longer matches.
A good maintenance checklist is short:
- Check whether each variant still represents one valid case.
- Confirm the discriminator names and values are still consistent across modules.
- Add or restore exhaustive checks where branches were weakened over time.
- Split large unions by domain if they are becoming hard to navigate.
- Review runtime validators and test fixtures alongside type updates.
If you are introducing discriminated unions into an older JavaScript or loosely typed TypeScript codebase, start with the highest-value boundaries first: async UI state, event payloads, and API results. These are the places where invalid combinations create the most confusion.
To make this practical, try the following next steps in your own codebase:
- Find one object type with three or more optional properties and ask whether it is hiding multiple variants.
- Replace boolean flag combinations with a single
statusfield. - Add an
assertNeverhelper to one reducer or event handler module. - Document one union as the canonical model for that domain, then reuse it instead of recreating similar shapes.
That small refactor is often enough to make the value obvious. Once your team sees clearer branching, safer refactors, and fewer impossible states, discriminated unions tend to become a default modeling tool rather than an advanced trick.