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
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
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
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
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.
Related Articles
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
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
Create reusable GitHub Actions workflows with inputs, secrets, and outputs to eliminate YAML duplication across repositories and teams efficiently.