TypeScriptDiscriminated Unions

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.

TS
// 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
  }
}
Note
The discriminant property (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.

TS
// 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.

TS
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.

TS
// 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

TS
// 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 }
Tip
When multiple cases share logic, you can group them with fallthrough in switch (as shown with 200/201 above) or extract them into a helper function. Both approaches preserve type narrowing.
Non-string Discriminants

The discriminant doesn't have to be a string. Numbers and booleans work too, though strings are most readable.

TS
// 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

TS
// 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
  }
}
Success
Discriminated unions are the cornerstone of type-safe state management in TypeScript. They eliminate entire classes of bugs by making invalid states literally unrepresentable — you cannot accidentally access a .data field while in the loading state because the loading variant simply doesn't have that property.