Discriminated unions in production TypeScript: patterns that scale
A senior frontend engineer at a payments company is debugging a production incident at 2 AM. The checkout button is disabled for users who have already completed their payment. The React component manages state with three boolean flags: loading, success, and error. Somewhere in the sequence of state updates that follow a payment attempt, the flags fall into an inconsistent combination — success: true and error: true simultaneously — and the component renders neither a confirmation screen nor an error message. It renders nothing. The engineer spends three hours tracing the setState calls before she realizes the problem is not a logic error in any individual update. The problem is that the type system permitted the invalid state to exist in the first place, and no amount of careful logic can fully prevent invalid states when the type itself does not rule them out.
// The state type that allowed the incident
type CheckoutState = {
loading: boolean;
success: boolean;
error: boolean;
errorMessage: string | null;
orderId: string | null;
};
This type has 2^3 = 8 possible boolean combinations for loading, success, and error alone. Of those, only four make any semantic sense: loading only, error only, success only, and none of them (the initial state). The other four combinations — including success: true, error: true — are bugs waiting to happen. Every developer who writes to this state must mentally track which combinations are valid. When four developers are writing separate features, that mental model diverges.
Discriminated unions: making impossible states unrepresentable
A discriminated union is a union type where each variant has a common field (the discriminant) that uniquely identifies which variant is active. All fields on a variant are only present when that variant is active. The pattern comes from functional programming, where it is called a sum type or tagged union, but TypeScript's implementation is practical and requires no functional programming background to use.
type CheckoutState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; orderId: string; completedAt: Date }
| { status: 'error'; errorMessage: string; retryable: boolean };
The impossible combinations are gone. There is no object that can simultaneously have status: 'success' and status: 'error' — a string literal can only hold one value. The orderId field exists only on the success variant. The errorMessage field exists only on the error variant. TypeScript enforces this at every assignment site.
// TypeScript catches invalid state construction at compile time
const state: CheckoutState = { status: 'success' };
// Error: Property 'orderId' is missing in type '{ status: "success"; }'
const state2: CheckoutState = {
status: 'success',
orderId: 'ord_123',
completedAt: new Date(),
errorMessage: 'oops' // Error: Object literal may only specify known properties
};
// TypeScript narrows the type inside switch/if statements:
function renderCheckout(state: CheckoutState): JSX.Element {
switch (state.status) {
case 'idle':
return <CheckoutForm />;
case 'loading':
return <ProcessingSpinner />;
case 'success':
// TypeScript knows state.orderId and state.completedAt exist here
return <OrderConfirmation orderId={state.orderId} completedAt={state.completedAt} />;
case 'error':
// TypeScript knows state.errorMessage and state.retryable exist here
return <PaymentError message={state.errorMessage} retryable={state.retryable} />;
}
}
The narrowing is the key benefit. Inside case 'success', TypeScript's control flow analysis knows the object must be { status: 'success'; orderId: string; completedAt: Date }. Accessing state.orderId is valid. Accessing state.errorMessage is a compile error. The developer cannot accidentally render error information in the success branch.
Exhaustive checking with never
The switch statement above works correctly today. When a new variant is added — say, status: 'pending' for payments awaiting bank authorization — the switch silently falls through the missing case. To make this addition a compile-time error, use the never type:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unhandled variant: ${JSON.stringify(value)}`);
}
function renderCheckout(state: CheckoutState): JSX.Element {
switch (state.status) {
case 'idle':
return <CheckoutForm />;
case 'loading':
return <ProcessingSpinner />;
case 'success':
return <OrderConfirmation orderId={state.orderId} completedAt={state.completedAt} />;
case 'error':
return <PaymentError message={state.errorMessage} retryable={state.retryable} />;
default:
return assertNever(state);
// TypeScript checks that 'state' is 'never' here.
// If any case is missing, 'state' would still have a type, not 'never',
// and TypeScript produces a compile error.
}
}
When a new variant is added to CheckoutState:
type CheckoutState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; orderId: string; completedAt: Date }
| { status: 'error'; errorMessage: string; retryable: boolean }
| { status: 'pending'; bankReferenceId: string }; // New variant
TypeScript immediately produces an error at the assertNever(state) call because state is now { status: 'pending'; bankReferenceId: string } in the default branch, not never. The developer is forced to handle the new variant before the code compiles. This is compiler-enforced exhaustiveness. Adding a new state variant and forgetting to update render logic becomes a compile-time error rather than a silent rendering bug discovered by a user.
Discriminated unions for API responses
API responses are among the most natural applications of discriminated unions, because every HTTP response is either a success or a failure, and the shape of the data differs between the two.
type ApiResponse<T> =
| { ok: true; data: T; statusCode: 200 | 201 }
| { ok: false; error: string; statusCode: 400 | 401 | 403 | 404 | 500 };
async function fetchUser(id: string): Promise<ApiResponse<User>> {
const response = await fetch(`/api/users/${id}`);
if (response.ok) {
const data = await response.json();
return { ok: true, data, statusCode: response.status as 200 | 201 };
} else {
const error = await response.json().catch(() => ({ message: 'Unknown error' }));
return {
ok: false,
error: error.message,
statusCode: response.status as 400 | 401 | 403 | 404 | 500
};
}
}
// Usage — TypeScript enforces handling both cases before accessing data
const result = await fetchUser('123');
if (result.ok) {
console.log(result.data.email); // TypeScript knows result.data is User
// result.error does not exist here — compile error if accessed
} else {
console.error(result.error); // TypeScript knows result.error is string
// result.data does not exist here — compile error if accessed
}
This pattern is especially useful when the API response type changes. Suppose the API starts returning a pagination cursor on success responses:
type ApiResponse<T> =
| { ok: true; data: T; statusCode: 200 | 201; cursor?: string }
| { ok: false; error: string; statusCode: 400 | 401 | 403 | 404 | 500 };
Every call site that handles the success branch can now access result.cursor. No call site can accidentally access it in the error branch. The discriminant (ok) gates access to the variant-specific fields.
Discriminated unions for form state
type FormState<T> =
| { phase: 'editing'; values: T; isDirty: boolean; validationErrors: Partial<Record<keyof T, string>> }
| { phase: 'submitting'; values: T }
| { phase: 'success'; submittedAt: Date }
| { phase: 'error'; values: T; error: string; retryCount: number };
function CheckoutForm() {
const [state, setState] = useState<FormState<CheckoutValues>>({
phase: 'editing',
values: initialValues,
isDirty: false,
validationErrors: {},
});
const handleSubmit = async () => {
if (state.phase !== 'editing') return;
// TypeScript knows state.values exists here (it is on the 'editing' variant)
setState({ phase: 'submitting', values: state.values });
try {
await submitOrder(state.values);
setState({ phase: 'success', submittedAt: new Date() });
} catch (error) {
setState({
phase: 'error',
values: state.values, // Preserve values for retry — available from 'submitting' variant
error: error instanceof Error ? error.message : 'Unknown error',
retryCount: state.phase === 'error' ? state.retryCount + 1 : 1,
});
}
};
if (state.phase === 'success') {
return <OrderConfirmation submittedAt={state.submittedAt} />;
// TypeScript knows state.submittedAt exists here
// state.values does not exist — the success variant does not carry form values
}
// state.phase is 'editing' | 'submitting' | 'error' here
// TypeScript knows state.values exists in all three remaining variants
return (
<form onSubmit={handleSubmit}>
<input
value={state.values.email}
disabled={state.phase === 'submitting'}
onChange={/* ... */}
/>
{state.phase === 'editing' && state.validationErrors.email && (
<span className="error">{state.validationErrors.email}</span>
)}
{state.phase === 'error' && (
<ErrorBanner message={state.error} retryCount={state.retryCount} />
)}
<button disabled={state.phase === 'submitting'}>
{state.phase === 'submitting' ? 'Processing...' : 'Submit'}
</button>
</form>
);
}
The form cannot be simultaneously submitting and have validation errors, because validation errors live only on the editing variant and are not present on the submitting variant. The form cannot be in a success state with editable values, because success carries only submittedAt. These constraints are structural, not enforced by developer discipline.
Nested discriminated unions for complex domain models
When domain objects have their own lifecycle, discriminated unions compose naturally:
type PaymentMethod =
| { type: 'card'; last4: string; brand: 'visa' | 'mastercard' | 'amex'; expiresAt: string }
| { type: 'bank'; accountLast4: string; routingNumber: string }
| { type: 'wallet'; provider: 'apple_pay' | 'google_pay' };
type SubscriptionStatus =
| { state: 'active'; renewsAt: Date; paymentMethod: PaymentMethod }
| { state: 'past_due'; dueAt: Date; paymentMethod: PaymentMethod; overdueAmount: number }
| { state: 'cancelled'; cancelledAt: Date; reason: string }
| { state: 'trialing'; trialEndsAt: Date };
function getSubscriptionSummary(sub: SubscriptionStatus): string {
switch (sub.state) {
case 'active':
return `Active, renews ${sub.renewsAt.toLocaleDateString()}`;
case 'past_due':
return `Past due — $${sub.overdueAmount} owed since ${sub.dueAt.toLocaleDateString()}`;
case 'cancelled':
return `Cancelled on ${sub.cancelledAt.toLocaleDateString()}: ${sub.reason}`;
case 'trialing':
return `Trial ends ${sub.trialEndsAt.toLocaleDateString()}`;
default:
return assertNever(sub);
}
}
// When the active variant is narrowed, the payment method can be further narrowed:
function getPaymentDisplay(sub: SubscriptionStatus): string {
if (sub.state !== 'active' && sub.state !== 'past_due') {
return 'No payment method';
}
// TypeScript knows sub.paymentMethod exists here
switch (sub.paymentMethod.type) {
case 'card':
return `${sub.paymentMethod.brand} ending in ${sub.paymentMethod.last4}`;
case 'bank':
return `Bank account ending in ${sub.paymentMethod.accountLast4}`;
case 'wallet':
return sub.paymentMethod.provider === 'apple_pay' ? 'Apple Pay' : 'Google Pay';
default:
return assertNever(sub.paymentMethod);
}
}
Nested discriminated unions handle realistic domain complexity without resorting to optional fields that are conditionally present based on invisible invariants.
Discriminated unions in backend event processing
The pattern applies equally to backend code. Event-driven systems process events of different types, each carrying different payloads:
type DomainEvent =
| { type: 'user.created'; userId: string; email: string; tenantId: string; occurredAt: Date }
| { type: 'user.deleted'; userId: string; reason: 'voluntary' | 'admin_action'; occurredAt: Date }
| { type: 'subscription.upgraded'; userId: string; fromPlan: string; toPlan: string; occurredAt: Date }
| { type: 'payment.failed'; userId: string; orderId: string; failureCode: string; occurredAt: Date };
// Event handlers are forced to handle every variant
async function processEvent(event: DomainEvent): Promise<void> {
switch (event.type) {
case 'user.created':
await sendWelcomeEmail(event.email);
await provisionTenant(event.tenantId, event.userId);
break;
case 'user.deleted':
await revokeAllSessions(event.userId);
if (event.reason === 'admin_action') {
await notifyAdmins(event.userId);
}
break;
case 'subscription.upgraded':
await grantUpgradedFeatures(event.userId, event.toPlan);
break;
case 'payment.failed':
await sendPaymentFailureNotification(event.userId, event.orderId);
break;
default:
assertNever(event); // Compile error when a new event type is added without a handler
}
}
When a new event type is added to the system — say, subscription.downgraded — the assertNever call in processEvent immediately becomes a compile error. Every event handler across the codebase that uses the same assertNever pattern is forced to handle the new type before the code compiles. Adding a new event variant is safe by construction.
What engineers typically try first (and why it falls short)
Before reaching for discriminated unions, most engineers reach for one of two patterns: boolean flags or optional fields.
Boolean flags (loading: boolean, error: Error | null) fail because they allow the impossible combinations described at the start. Every consumer of the state must defensively guard against combinations that should not exist. Over time, those guards accumulate technical debt.
Optional fields (user?: User, error?: Error) have the same problem. A type like { user?: User; error?: Error } permits { user: someUser, error: someError } even though that combination is semantically impossible. Optional fields with conditional meaning — "this field exists if this other field has this value" — encode invariants in documentation rather than types.
The common intermediate step is adding comments explaining which combinations are valid:
// loading and error are mutually exclusive
// user is only present when loading is false and error is null
type UserState = {
loading: boolean;
error: Error | null;
user: User | null;
};
Comments are not enforced by the compiler. Three months later, a developer who did not read the comment writes code that sets both loading: true and error: someError. Discriminated unions move the constraint from prose to the type system.
Common mistakes
Using string literals when literal types are needed. The discriminant works because TypeScript narrows based on literal types. If the discriminant field is typed as string rather than a literal union, narrowing does not work:
// WRONG: 'status' is typed as string — no narrowing
type State = {
status: string;
data?: User;
};
// CORRECT: status is a literal union — narrowing works
type State =
| { status: 'loading' }
| { status: 'success'; data: User };
Sharing fields across all variants when they should be variant-specific. Putting a field on a base type and extending it undermines the discriminated union pattern:
// WRONG: 'id' is on a base type extended by all variants
type BaseState = { id: string };
type LoadingState = BaseState & { status: 'loading' };
type SuccessState = BaseState & { status: 'success'; data: User };
// Now 'id' is always present regardless of status
// RIGHT: Only put fields on variants where they are semantically meaningful
type State =
| { status: 'loading' }
| { status: 'success'; id: string; data: User };
// id is only present when the fetch succeeded and an entity has been returned
Skipping the assertNever exhaustiveness check. A switch without assertNever in the default branch compiles silently when a new variant is added. The exhaustiveness check is the mechanism that makes the discriminated union pattern scale — it converts the runtime cost of missing a case into a compile-time error.
Forward-looking considerations
As TypeScript evolves, discriminated unions become more powerful. The pattern-matching proposal for JavaScript would allow syntax like:
// Hypothetical future syntax (proposal stage)
const result = match(state) {
when { status: 'idle' } -> <CheckoutForm />,
when { status: 'loading' } -> <Spinner />,
when { status: 'success', orderId } -> <Confirmation orderId={orderId} />,
when { status: 'error', errorMessage } -> <Error message={errorMessage} />,
};
Even without pattern matching syntax, discriminated unions today work with TypeScript's control flow analysis. The switch plus assertNever pattern provides most of the safety that native pattern matching would, with full TypeScript 5.x support.
The broader trend in TypeScript codebase design is toward encoding domain invariants structurally — in types — rather than procedurally in runtime checks. Discriminated unions are the most direct expression of this approach for state and entity types. The engineer who debugged the 2 AM checkout incident rewrote the state type as a discriminated union after the incident. The refactor took four hours. It eliminated an entire class of impossible state bugs from the component — and TypeScript enforced the elimination at every future change site without any tests specifically written to cover impossible state combinations.