TypeScript discriminated unions are the single best tool for making impossible states impossible to represent, and most developers only discover them after shipping a bug caused by loading, error, and data fields that could all technically be true at once.
A discriminated union is a union of object types that share a common literal property — the "discriminant" — which TypeScript uses to narrow the type automatically inside if statements or switch blocks. Instead of a loose object with optional fields that might contradict each other, you get a type where each valid state is explicit and mutually exclusive.
Why Discriminated Unions Matter (and When to Skip Them)
The alternative to a discriminated union is usually a "kitchen sink" interface:
interface RequestState {
loading: boolean;
data?: User;
error?: string;
}
This type technically allows loading: true and error: "failed" at the same time — a state that should never exist but that TypeScript happily accepts. Discriminated unions close that hole entirely.
Skip them for genuinely simple data with no meaningful "state" — a plain User interface doesn't need a discriminant. Reach for them the moment a type represents one of several distinct states.
Getting Started with Discriminated Unions
Model the same request state as a proper discriminated union:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function render(state: RequestState<User>) {
switch (state.status) {
case "idle":
return "Waiting to start";
case "loading":
return "Loading...";
case "success":
return state.data.name; // TypeScript knows `data` exists here
case "error":
return state.error; // TypeScript knows `error` exists here
}
}
Notice state.data is only accessible inside the "success" case — TypeScript narrows the union automatically based on the status literal.
Core Discriminated Union Concepts Every Developer Should Know
The discriminant must be a literal type, not string or boolean. status: string won't narrow — TypeScript needs status: "loading" (a specific literal) to distinguish the branches.
Exhaustiveness checking catches missing cases at compile time. Add a never check in the default branch, and TypeScript errors if you add a new state and forget to handle it:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function render(state: RequestState<User>) {
switch (state.status) {
case "idle": return "Waiting to start";
case "loading": return "Loading...";
case "success": return state.data.name;
case "error": return state.error;
default: return assertNever(state); // compile error if a case is missing
}
}
Discriminated unions model API responses cleanly. A common real-world pattern is a result type instead of throwing errors:
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const user = await db.users.findById(id);
return { ok: true, value: user };
} catch (err) {
return { ok: false, error: String(err) };
}
}
Common Discriminated Union Mistakes and How to Fix Them
Mistake 1: using a boolean flag instead of a literal string discriminant. { success: boolean; data?: T; error?: string } still allows invalid combinations. Fix: convert boolean flags into explicit literal-string states.
Mistake 2: narrowing on the wrong property. If two members of the union share overlapping optional fields, TypeScript can fail to narrow correctly. Fix: make sure every member has the discriminant field with a unique literal value, and nothing else overlaps ambiguously.
Mistake 3: skipping exhaustiveness checks. Without the never check in a default case, adding a new union member later won't produce a compile error anywhere that switches on it — the bug surfaces at runtime instead. Fix: always add the assertNever pattern to switch statements over discriminated unions.
When Should You Use a Discriminated Union?
Any time a value represents one of several mutually exclusive states: loading states, API results, form validation outcomes, WebSocket message types, or Redux-style actions. If you catch yourself writing multiple optional fields that "shouldn't" all be present at once, that's the signal to switch to a discriminated union.
Discriminated Unions in Production
They pair extremely well with Zod for runtime validation — z.discriminatedUnion("status", [...]) gives you the same exhaustiveness guarantees at runtime that TypeScript gives you at compile time, which matters for anything crossing a network boundary like a webhook payload or API response.
Next time you write an interface with more than one optional field, ask whether it's actually several distinct states pretending to be one type — it usually is.