Making Illegal States Unrepresentable with TypeScript Discriminated Unions
Stop using optional fields for state management. Learn how to use TypeScript discriminated unions and the 'never' type to make illegal states unrepresentable and ensure exhaustive logic.
08 May 2026, 15:00 UTC

The Problem: The 'Optional Field' Trap
When modeling API responses or complex application states, a common instinct is to create a single interface with several optional fields. Consider a payment process state:
interface PaymentState {
status: 'idle' | 'loading' | 'success' | 'failure';
receiptId?: string;
errorCode?: number;
}
The problem here is that the type system allows impossible states. You could accidentally have a state where status is 'idle' but a receiptId is present, or a 'failure' status without an errorCode. To access these fields, you are forced to use optional chaining (?.) or non-null assertions (!), even when you know logically that a success state must have a receipt.
The Solution: Discriminated Unions
A discriminated union is a set of types that share a common property—the discriminant (or tag)—which has a unique literal value for each member of the union. By splitting one large interface into several smaller, specific types, you ensure that only the data relevant to a specific state exists.
Worked Example: Payment State Machine
Below is a robust implementation of a payment state. This requires TypeScript 4.9+ if using the satisfies operator for validation.
type PaymentState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; receiptId: string }
| { status: 'failure'; code: number; message: string };
function handlePayment(state: PaymentState) {
switch (state.status) {
case 'idle':
return 'Waiting for user...';
case 'loading':
return 'Processing payment...';
case 'success':
// TypeScript narrows 'state' to the success variant here.
// receiptId is guaranteed to be a string, no optional chaining needed.
return `Payment complete! Receipt: ${state.receiptId}`;
case 'failure':
// state is narrowed to the failure variant.
return `Error ${state.code}: ${state.message}`;
default:
// Exhaustiveness check (see below)
const _exhaustiveCheck: never = state;
return _exhaustiveCheck;
}
}
Ensuring Total Coverage with never
A significant risk in large unions is adding a new state (e.g., 'refunded') but forgetting to update every switch or if/else block across the codebase. You can turn this silent bug into a build-time error using the never type.
In the example above, the default branch assigns the remaining state to a variable of type never. Because never represents a value that should not exist, the compiler will throw an error if any member of the PaymentState union is not handled by a previous case. This forces the developer to explicitly handle the new state before the code will compile.
The Runtime Gap and Validation
It is critical to remember that TypeScript types are erased during compilation. If you receive a JSON payload from an API via JSON.parse(), TypeScript will trust whatever type you cast it to, but it cannot verify the data actually matches your union at runtime.
To bridge this gap, you must use a Type Guard. This is a function that performs a runtime check and tells the compiler the result:
function isPaymentState(input: any): input is PaymentState {
return
input &&
typeof input.status === 'string' &&
['idle', 'loading', 'success', 'failure'].includes(input.status);
}
Risk: If you skip this validation and cast an API response using as PaymentState, your code may crash when accessing state.receiptId if the API returned an unexpected format, despite the compiler saying it is safe.
Trade-offs and Limitations
- Boilerplate: Discriminated unions require more initial typing than a single interface with optional fields.
- Strict Mode: This pattern relies heavily on
strictNullChecks. If strict mode is disabled, the narrowing behavior is significantly weakened. - Discriminant Type: The tag must be a literal (e.g.,
'success') rather than a generalstring. If the tag is widened tostring, TypeScript cannot narrow the union to a specific member.
Verification Checklist
To verify this pattern in your project:
- Run
tsc --noEmitwithstrict: truein yourtsconfig.json. - Add a new member to your union type.
- Confirm that the
defaultbranch in yourswitchstatements now triggers a compiler error.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.