TypeScript Discriminated Unions and Exhaustive Pattern Matching
How to use TypeScript discriminated unions and exhaustive matching to eliminate impossible states and make illegal data representations unrepresentable.
A common source of production bugs is state that should not be possible but is. An order that is simultaneously paid and refunded. A request with a userId but no user object. A response that has both data and error set to non-null values at the same time.
TypeScript discriminated unions are the tool for making these states not just unlikely, but literally unrepresentable. Combined with exhaustive matching, they turn missing branches into compile errors.
What Discriminated Unions Are
A discriminated union is a union type where each member has a common literal field (the discriminant) that TypeScript can use to narrow between them:
type Result<T> =
| { status: "success"; data: T }
| { status: "error"; code: string; message: string }
| { status: "loading" };The status field is the discriminant. When you check result.status, TypeScript narrows the type automatically:
function render(result: Result<User>) {
if (result.status === "loading") {
return "Loading...";
}
if (result.status === "error") {
return `Error ${result.code}: ${result.message}`;
// TypeScript knows: result.data does not exist here
}
return result.data.email;
// TypeScript knows: result.data is User here
}No casting. No optional chaining through properties that do not apply. The type narrows based on the discriminant and TypeScript knows exactly what fields exist in each branch.
Making Impossible States Unrepresentable
The real power of discriminated unions is not just narrowing — it is modeling domain concepts so that invalid combinations cannot be constructed.
Consider a payment record:
// Without discriminated unions — any combination is possible
interface Payment {
status: "pending" | "completed" | "refunded";
completedAt?: Date;
refundedAt?: Date;
transactionId?: string;
}
// Nothing prevents: { status: "pending", completedAt: new Date(), refundedAt: new Date() }With discriminated unions:
type Payment =
| { status: "pending" }
| { status: "completed"; completedAt: Date; transactionId: string }
| { status: "refunded"; completedAt: Date; transactionId: string; refundedAt: Date };A pending payment cannot have a completedAt. A completed payment must have a transactionId. A refunded payment must have both timestamps. These constraints are enforced by the type system — no runtime checks needed for internal code.
TypeScript Discriminated Unions with Switch Statements
Switch statements are the natural fit for discriminated unions:
function processPayment(payment: Payment): string {
switch (payment.status) {
case "pending":
return "Awaiting payment";
case "completed":
return `Completed via ${payment.transactionId}`;
case "refunded":
return `Refunded on ${payment.refundedAt.toDateString()}`;
}
}TypeScript knows the exhaustive set of status values and will warn if a case is missing — but only if you help it.
Exhaustive Matching with the never Type
TypeScript's exhaustive checking only kicks in when you tell it to. The never type is the mechanism:
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function processPayment(payment: Payment): string {
switch (payment.status) {
case "pending":
return "Awaiting payment";
case "completed":
return `Completed via ${payment.transactionId}`;
case "refunded":
return `Refunded on ${payment.refundedAt.toDateString()}`;
default:
return assertNever(payment);
// If Payment gains a new variant and this switch is not updated,
// the default branch receives that variant and TypeScript reports a type error.
}
}When every case is handled, payment in the default branch has type never — because nothing is left. If you add a new variant to Payment without updating the switch, payment in default is no longer never and TypeScript reports an error at that call to assertNever.
This means adding a new state to a union type is a compile-time check that every switch on that union is updated. Across a codebase with dozens of handlers, this is the difference between a confident refactor and a guessing game.
Discriminated Unions for API Responses
The pattern transfers directly to API design. Instead of optional fields and status strings, model the actual cases:
type ApiResult<T> =
| { ok: true; data: T; statusCode: 200 | 201 }
| { ok: false; error: string; statusCode: 400 | 401 | 403 | 404 | 500 };
async function fetchUser(id: string): Promise<ApiResult<User>> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return {
ok: false,
error: await response.text(),
statusCode: response.status as 400 | 401 | 403 | 404 | 500,
};
}
return { ok: true, data: await response.json(), statusCode: 200 };
}Callers check result.ok before accessing result.data. TypeScript enforces it. No "data might be undefined even though the request succeeded" ambiguity.
When to Use Discriminated Unions
Use them when a type has multiple states with different valid fields, and the right fields depend on which state you are in. Common patterns:
- Request/response state (loading, success, error)
- Domain entity states (order, payment, subscription lifecycle stages)
- Feature flags with different configurations per variant
- Event payloads where different events carry different data
Do not use them for simple boolean flags or when the states genuinely share all the same fields. If every variant has the same shape, a plain interface with an optional status string is cleaner.
Designing state models that prevent invalid combinations is one of the highest-leverage things you can do in a TypeScript codebase. If your team is building a complex domain model and wants it typed correctly, Clixo can help you get the foundations right.