Discriminated Unions
A discriminated union (also called a tagged union or algebraic data type) is a union of types that all share a common literal property — called the discriminant or tag. TypeScript uses the discriminant to narrow the type in switch statements and if branches, giving you a safe and exhaustive way to handle every case.
This is one of the most powerful patterns in TypeScript and is used everywhere: state machines, API response types, event systems, domain models.
The Core Pattern
Every member of the union has the same property name, but with a different literal type value. This shared literal property is the discriminant.
// Each shape has a 'kind' property — that's the discriminant
interface Circle {
kind: 'circle'
radius: number
}
interface Rectangle {
kind: 'rectangle'
width: number
height: number
}
interface Triangle {
kind: 'triangle'
base: number
height: number
}
type Shape = Circle | Rectangle | Triangle
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript knows: shape is Circle — can access .radius
return Math.PI * shape.radius ** 2
case 'rectangle':
// TypeScript knows: shape is Rectangle — can access .width, .height
return shape.width * shape.height
case 'triangle':
// TypeScript knows: shape is Triangle — can access .base, .height
return 0.5 * shape.base * shape.height
}
}kind) must be a literal type — a specific string, number, or boolean value, not just string. TypeScript uses it to know exactly which variant you're in.Real-World: Async State Machine
The most common real-world use is modeling asynchronous state. Instead of a sprawling object with many optional properties, each state is its own precise type.
// Before: "stringly typed" state with optional fields — confusing
interface BadState {
isLoading: boolean
data?: User[]
error?: string
}
// Is isLoading=false + data=undefined a valid state? Is it error or idle?
// After: discriminated union — each state is unambiguous
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string; retryCount: number }
interface User {
id: number
name: string
}
function renderUsers(state: FetchState<User[]>): string {
switch (state.status) {
case 'idle':
return 'Click to load users'
case 'loading':
return 'Loading...'
case 'success':
return state.data.map(u => u.name).join(', ')
case 'error':
return `Error: ${state.error} (attempt ${state.retryCount})`
}
}Narrowing with if Statements
You're not limited to switch. You can use any conditional structure — the discriminant
narrows just as well inside if blocks.
type ApiResult<T> =
| { ok: true; value: T }
| { ok: false; error: string }
function processResult<T>(result: ApiResult<T>): T {
if (result.ok) {
// result is { ok: true; value: T }
return result.value
}
// result is { ok: false; error: string }
throw new Error(result.error)
}
// Usage
const success: ApiResult<number> = { ok: true, value: 42 }
const failure: ApiResult<number> = { ok: false, error: 'Not found' }
console.log(processResult(success)) // 42
// processResult(failure) // throws "Not found"42
Discriminated Unions for Events
Event systems are a natural fit for discriminated unions. Each event type carries exactly the data it needs — no more, no less.
// DOM-like event system with precise types
type AppEvent =
| { type: 'user:login'; userId: string; timestamp: Date }
| { type: 'user:logout'; userId: string; reason: 'manual' | 'timeout' }
| { type: 'cart:add'; productId: string; quantity: number }
| { type: 'cart:remove'; productId: string }
| { type: 'payment:success'; orderId: string; amount: number }
| { type: 'payment:failed'; orderId: string; code: string }
function handleEvent(event: AppEvent) {
switch (event.type) {
case 'user:login':
console.log(`User ${event.userId} logged in at ${event.timestamp}`)
break
case 'user:logout':
console.log(`User ${event.userId} logged out: ${event.reason}`)
break
case 'cart:add':
console.log(`Added ${event.quantity}x product ${event.productId}`)
break
case 'cart:remove':
console.log(`Removed product ${event.productId}`)
break
case 'payment:success':
console.log(`Order ${event.orderId} paid: $${event.amount}`)
break
case 'payment:failed':
console.log(`Payment failed for ${event.orderId}: ${event.code}`)
break
}
}Discriminated Unions for API Responses
// Model every possible HTTP response shape
type HttpResponse<T> =
| { status: 200; body: T }
| { status: 201; body: T; location: string }
| { status: 400; error: string; fields?: Record<string, string> }
| { status: 401; reason: 'expired' | 'invalid' | 'missing' }
| { status: 404 }
| { status: 500; message: string }
function handleUserResponse(res: HttpResponse<User>) {
switch (res.status) {
case 200:
case 201:
// Both have .body: User
return res.body
case 400:
throw new Error(`Bad request: ${res.error}`)
case 401:
throw new Error(`Unauthorized: ${res.reason}`)
case 404:
return null
case 500:
throw new Error(`Server error: ${res.message}`)
}
}
interface User { id: number; name: string }Non-string Discriminants
The discriminant doesn't have to be a string. Numbers and booleans work too, though strings are most readable.
// Boolean discriminant
type MaybeUser =
| { found: true; user: { id: number; name: string } }
| { found: false; reason: string }
function greet(result: MaybeUser) {
if (result.found) {
console.log(`Hello, ${result.user.name}`)
} else {
console.log(`User not found: ${result.reason}`)
}
}
// Number discriminant (less common)
type VersionedData =
| { version: 1; legacyId: string }
| { version: 2; id: number; namespace: string }
function migrate(data: VersionedData) {
if (data.version === 1) {
return { version: 2 as const, id: parseInt(data.legacyId), namespace: 'default' }
}
return data
}Common Mistakes
Mistake | Problem | Fix |
|---|---|---|
Non-literal discriminant type | string alone does not narrow | Use literal types: "circle" not string |
Missing discriminant on one member | That member is not narrowed by switch | All union members need the same discriminant field |
No default/exhaustive check | Adding new variants silently breaks switch | Add exhaustiveness check with never |
Using optional discriminant | undefined values prevent narrowing | Make the discriminant required |
Utility: Creating Typed Action Creators
// Redux-style typed actions using discriminated unions
type Action =
| { type: 'INCREMENT'; amount: number }
| { type: 'DECREMENT'; amount: number }
| { type: 'RESET' }
// Helper to create type-safe action creators
function createAction<T extends Action['type']>(
type: T,
...args: Extract<Action, { type: T }> extends { type: T } & infer Rest
? [Rest] extends [Record<string, never>] ? [] : [Omit<Extract<Action, { type: T }>, 'type'>]
: []
): Extract<Action, { type: T }> {
return { type, ...args[0] } as Extract<Action, { type: T }>
}
// Simpler practical version:
const increment = (amount: number): Action => ({ type: 'INCREMENT', amount })
const decrement = (amount: number): Action => ({ type: 'DECREMENT', amount })
const reset = (): Action => ({ type: 'RESET' })
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'INCREMENT': return state + action.amount
case 'DECREMENT': return state - action.amount
case 'RESET': return 0
}
}.data field while in the loading state because the loading variant simply doesn't have that property.