Loading…
Loading…
Model complex application state with TypeScript discriminated unions to eliminate impossible states and get exhaustive type checking for free.
Tags
Add a literal type or status field to each union member so TypeScript can narrow types automatically in switch statements, making impossible states unrepresentable.
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.
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} />;
}
}neverfunction 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);
}
}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.
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
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.
Five essential built-in generic utility types in TypeScript that will save you hundreds of lines of code.
Generate dynamic Open Graph images in Next.js using the ImageResponse API with custom fonts, gradients, and data-driven content for social sharing.
Create reusable GitHub Actions workflows with inputs, secrets, and outputs to eliminate YAML duplication across repositories and teams efficiently.