Blog/Quick Tips & Snippets/TypeScript Discriminated Unions for Type-Safe State
POST
September 25, 2025
LAST UPDATEDSeptember 25, 2025

TypeScript Discriminated Unions for Type-Safe State

Model complex application state with TypeScript discriminated unions to eliminate impossible states and get exhaustive type checking for free.

Tags

TypeScriptUnionsType SafetyPatterns
TypeScript Discriminated Unions for Type-Safe State
1 min read

TypeScript Discriminated Unions for Type-Safe State

TL;DR

Add a literal type or status field to each union member so TypeScript can narrow types automatically in switch statements, making impossible states unrepresentable.

The Problem

You model async state with optional fields: { data?: User; error?: Error; isLoading: boolean }. Nothing stops you from setting both data and error simultaneously, or forgetting to check isLoading before accessing data. These impossible states cause runtime bugs that TypeScript cannot catch.

The Solution

typescript
type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };
 
function renderUser(state: AsyncState<User>) {
  switch (state.status) {
    case "idle":
      return <p>Click to load</p>;
    case "loading":
      return <Spinner />;
    case "success":
      // TypeScript knows state.data exists here
      return <UserCard name={state.data.name} />;
    case "error":
      // TypeScript knows state.error exists here
      return <Alert message={state.error.message} />;
  }
}

Exhaustive Checking with never

typescript
function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${value}`);
}
 
function handleState(state: AsyncState<User>) {
  switch (state.status) {
    case "idle":
      return null;
    case "loading":
      return null;
    case "success":
      return state.data;
    case "error":
      return null;
    default:
      // If you add a new status and forget to handle it,
      // TypeScript errors here at compile time
      return assertNever(state);
  }
}

Real-World Example: Payment Status

typescript
type Payment =
  | { type: "pending"; createdAt: Date }
  | { type: "completed"; paidAt: Date; transactionId: string }
  | { type: "failed"; failedAt: Date; reason: string }
  | { type: "refunded"; refundedAt: Date; refundAmount: number };

Each variant carries only the fields relevant to that state. You cannot accidentally access transactionId on a failed payment.

Why This Works

TypeScript uses the shared literal property (the discriminant) to narrow the union to a single member inside each case branch. This is called control flow analysis. The never type in the default branch acts as an exhaustiveness check: if you add a new union member but forget to handle it, the value will not be assignable to never, producing a compile-time error. No runtime overhead is added since discriminated unions are plain objects.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

TypeScript Utility Types You Should Know
Feb 10, 20263 min read
TypeScript
Cheatsheet

TypeScript Utility Types You Should Know

Five essential built-in generic utility types in TypeScript that will save you hundreds of lines of code.

Generate Dynamic OG Images in Next.js
Feb 08, 20262 min read
Next.js
OG Images
SEO

Generate Dynamic OG Images in Next.js

Generate dynamic Open Graph images in Next.js using the ImageResponse API with custom fonts, gradients, and data-driven content for social sharing.

GitHub Actions Reusable Workflows: Stop Repeating Yourself
Jan 22, 20263 min read
GitHub Actions
CI/CD
DevOps

GitHub Actions Reusable Workflows: Stop Repeating Yourself

Create reusable GitHub Actions workflows with inputs, secrets, and outputs to eliminate YAML duplication across repositories and teams efficiently.