Resourcesโ€บTypeScript Tipsโ€บDiscriminated Unions: The Pattern That Eliminates Bugs
๐Ÿ“˜TypeScript Tipsโ€” Discriminated Unions: The Pattern That Eliminates Bugsโฑ 6 min

Discriminated Unions: The Pattern That Eliminates Bugs

Discriminated unions are TypeScript's most powerful pattern for modeling state. Here's how to use them to make impossible states unrepresentable.

๐Ÿ“…January 30, 2026โœTechTwitter.iotypescriptdiscriminated-unionspatterns

The Problem: State You Can't Trust

Every developer has written code like this:

interface AsyncState {
  loading: boolean
  data: User | null
  error: Error | null
}

Seems fine. But this type allows combinations that shouldn't exist:

  • loading: true, data: { ... } โ€” loading and has data?
  • loading: false, data: null, error: null โ€” not loading, no data, no error?
  • loading: true, error: Error โ€” loading and errored?

These are impossible states that your code has to defensively handle โ€” or it crashes.


The Solution: Discriminated Unions

A discriminated union is a union of types that each have a shared literal type field (the discriminant) that TypeScript uses to narrow the type:

type AsyncState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error }

Now impossible states are impossible in the type system. You can't have status: 'loading' and data at the same time.


Using Discriminated Unions in Practice

TypeScript narrows the type when you switch on the discriminant:

function renderState(state: AsyncState) {
  switch (state.status) {
    case 'idle':
      return <IdleView />
    case 'loading':
      return <Spinner />
    case 'success':
      return <UserCard user={state.data} /> // TypeScript knows data exists here
    case 'error':
      return <ErrorMessage error={state.error} /> // TypeScript knows error exists
  }
}

No data?. optional chaining needed. No if (data !== null) guards. The type tells TypeScript exactly what's available in each branch.


Exhaustiveness Checking

Add a never check to ensure you handle every case:

function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`)
}

function renderState(state: AsyncState) {
  switch (state.status) {
    case 'idle': return <IdleView />
    case 'loading': return <Spinner />
    case 'success': return <UserCard user={state.data} />
    case 'error': return <ErrorMessage error={state.error} />
    default: return assertNever(state) // Error if you add a new status and forget this function
  }
}

Now when you add a new status variant to AsyncState, TypeScript will error in renderState until you handle it. Compile-time exhaustiveness checking.


Real-World Example: API Result Type

type ApiResult<T> =
  | { ok: true; data: T; status: number }
  | { ok: false; error: string; status: number }

async function fetchUser(id: string): Promise<ApiResult<User>> {
  try {
    const res = await fetch(`/api/users/${id}`)
    if (!res.ok) {
      return { ok: false, error: await res.text(), status: res.status }
    }
    return { ok: true, data: await res.json(), status: res.status }
  } catch (e) {
    return { ok: false, error: String(e), status: 0 }
  }
}

// Using it:
const result = await fetchUser('123')
if (result.ok) {
  console.log(result.data.name) // TypeScript knows data exists
} else {
  console.log(result.error) // TypeScript knows error exists
}

Beyond Status Fields: Any Literal Works

The discriminant doesn't have to be called status. Any literal type property works:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rect'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number }

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':   return Math.PI * shape.radius ** 2
    case 'rect':     return shape.width * shape.height
    case 'triangle': return 0.5 * shape.base * shape.height
  }
}

Combining with Utility Types

// Extract only the success state
type SuccessState = Extract<AsyncState, { status: 'success' }>
// { status: 'success'; data: User }

// Get the data type from success state
type UserData = SuccessState['data']
// User

Key Takeaways

  • Discriminated unions make impossible states unrepresentable in the type system
  • The discriminant is a shared literal type field (like status, kind, type)
  • TypeScript automatically narrows the type in switch/if blocks on the discriminant
  • Use assertNever for exhaustiveness checking โ€” catch missing cases at compile time
  • This pattern eliminates entire classes of null-check bugs and defensive programming