In type theory, discriminated unions (also known as Tagged Unions, Algebraic Data Types, or Sum Types) represent a data structure capable of holding one of several strictly defined states. Each state possesses a shared literal key property (the discriminant), enabling TypeScript's Control Flow Analysis (CFA) to deterministically narrow the variable to a specific variant.
In enterprise software engineering, discriminated unions serve as a foundational mechanism for making invalid application states impossible at compile time (Make Impossible States Impossible). The classic antipattern of creating a "bag of optional fields with boolean flags" leads to a combinatorial explosion of invalid in-memory states:
+-------------------------------------------------------------------------+
| IN-MEMORY STATE MODEL COMPARISON |
+-------------------------------------------------------------------------+
| [Antipattern: Flat object with boolean flags] |
| interface BadState { |
| isLoading: boolean; |
| isError: boolean; |
| data?: UserData; |
| error?: Error; |
| } |
| Potential states: 2^2 * 2 * 2 = 16 combinations |
| (Including contradictory states: isLoading=true AND isError=true) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [Discriminated Union: Strict Finite State Machine] |
| type RemoteData<T, E> = |
| | { status: 'idle' } |
| | { status: 'loading' } |
| | { status: 'success'; data: T } |
| | { status: 'error'; error: E }; |
| Exactly 4 mutually exclusive states. Invalid states cannot be represented!|
+-------------------------------------------------------------------------+The Exhaustive Checking pattern is anchored in the semantics of the never type. In TypeScript, never is the bottom type (an empty set containing no values). No value other than never itself can be assigned to a variable of type never.
When the compiler's Control Flow Analysis evaluates every case branch of a discriminated union, the variable's type within the default block narrows down to never. Passing this variable to an assertNever(x: never): never utility compiles cleanly. However, if an engineer introduces a new variant to the union and omits its corresponding handler branch, the remaining type in default becomes that unhandled variant, prompting the compiler to reject the code with a type error.
+-------------------------------------------------------------------------+
| CONTROL FLOW TYPE NARROWING PIPELINE |
+-------------------------------------------------------------------------+
| Input: action: PaymentAction (Card | Crypto | BankWire) |
| | |
| |-- case 'card': action narrowed to CardPayment |
| |-- case 'crypto': action narrowed to CryptoPayment |
| |-- case 'bank_wire': action narrowed to BankWirePayment |
| | |
| `-- default: |
| Remaining type: never (all variants exhausted) |
| assertNever(action) -> Compilation passes! |
+-------------------------------------------------------------------------+
|
(New variant introduced: 'apple_pay')
v
+-------------------------------------------------------------------------+
| default: |
| Remaining type: ApplePayPayment |
| assertNever(action) |
| ERROR: Argument of type 'ApplePayPayment' is not assignable to 'never' |
+-------------------------------------------------------------------------+assertNever and Exhaustive Matching#The assertNever helper accepts an argument typed as never and throws a runtime exception if the code path is ever reached (for instance, via unsafe type assertions or deserialization anomalies):
/**
* Enforces compile-time exhaustive checking for union variants.
* Throws a runtime error if a type invariant is violated.
*/
export function assertNever(value: never, customMessage?: string): never {
throw new Error(
customMessage ??
`[ExhaustiveCheckError] Unhandled union variant: ${JSON.stringify(value)}`
);
}Consider an enterprise e-commerce order lifecycle. Each state carries only the properties relevant to that specific phase:
import { assertNever } from '../utils/assertNever';
export interface OrderDraft {
readonly status: 'draft';
readonly createdAt: Date;
readonly items: ReadonlyArray<{ itemId: string; quantity: number }>;
}
export interface OrderPendingPayment {
readonly status: 'pending_payment';
readonly orderId: string;
readonly amountCents: number;
readonly paymentGatewayUrl: string;
readonly expiresAt: Date;
}
export interface OrderPaid {
readonly status: 'paid';
readonly orderId: string;
readonly transactionId: string;
readonly paidAt: Date;
}
export interface OrderCancelled {
readonly status: 'cancelled';
readonly orderId: string;
readonly reason: 'timeout' | 'user_rejected' | 'fraud_detected';
readonly cancelledAt: Date;
}
export interface OrderFulfilled {
readonly status: 'fulfilled';
readonly orderId: string;
readonly trackingNumber: string;
readonly deliveredAt: Date;
}
// Root discriminated union
export type OrderState =
| OrderDraft
| OrderPendingPayment
| OrderPaid
| OrderCancelled
| OrderFulfilled;
/**
* Pure function formatting order status for user presentation.
* The compiler guarantees all 5 branches are handled.
*/
export function formatOrderStatusMessage(state: OrderState): string {
switch (state.status) {
case 'draft':
return `Draft created on ${state.createdAt.toLocaleDateString()}`;
case 'pending_payment':
return `Awaiting payment until ${state.expiresAt.toLocaleTimeString()}. Gateway: ${state.paymentGatewayUrl}`;
case 'paid':
return `Paid (Transaction: ${state.transactionId})`;
case 'cancelled':
return `Order cancelled. Reason: ${state.reason}`;
case 'fulfilled':
return `Delivered. Tracking number: ${state.trackingNumber}`;
default:
// Introducing a new status (e.g., 'refunded') triggers a compile error here
return assertNever(state);
}
}To enable clean, declarative data processing pipelines, we can construct a higher-order matchStatus pattern matcher:
import { assertNever } from './assertNever';
type MatcherTable<U extends { status: string }, R> = {
[K in U['status']]: (variant: Extract<U, { status: K }>) => R;
};
/**
* Declarative pattern matching for discriminated unions keyed on 'status'.
*/
export function matchStatus<U extends { status: string }, R>(
value: U,
table: MatcherTable<U, R>
): R {
const handler = table[value.status as U['status']];
if (!handler) {
return assertNever(value as never, `No handler found for status: ${value.status}`);
}
return handler(value as Extract<U, { status: typeof value.status }>);
}Define a set of interfaces sharing a common string literal discriminant ('status') and compose them into a union type OrderState.
The compiler parses switch/case blocks. Inside each case, the union narrows to the matching variant by discriminant literal.
In the default branch, the compiler computes the set difference: T_initial \ (A | B | C). When the difference is empty, the remaining type is never.
If default evaluates to never, assertNever compiles cleanly. If any variant was missed, compiler emits error TS2345.
import { assertNever } from '../utils/assertNever';
export type RemoteData<TData, TError = Error> =
| { readonly status: 'idle' }
| { readonly status: 'loading' }
| { readonly status: 'success'; readonly data: TData }
| { readonly status: 'failure'; readonly error: TError };
export const RemoteData = {
idle: <T, E = Error>(): RemoteData<T, E> => ({ status: 'idle' }),
loading: <T, E = Error>(): RemoteData<T, E> => ({ status: 'loading' }),
success: <T, E = Error>(data: T): RemoteData<T, E> => ({ status: 'success', data }),
failure: <T, E = Error>(error: E): RemoteData<T, E> => ({ status: 'failure', error }),
isIdle: <T, E>(rd: RemoteData<T, E>): rd is { readonly status: 'idle' } =>
rd.status === 'idle',
isLoading: <T, E>(rd: RemoteData<T, E>): rd is { readonly status: 'loading' } =>
rd.status === 'loading',
isSuccess: <T, E>(rd: RemoteData<T, E>): rd is { readonly status: 'success'; readonly data: T } =>
rd.status === 'success',
isFailure: <T, E>(rd: RemoteData<T, E>): rd is { readonly status: 'failure'; readonly error: E } =>
rd.status === 'failure',
/**
* Exhaustive state folder ensuring all four branches are handled
*/
fold: <T, E, R>(
rd: RemoteData<T, E>,
matchers: {
onIdle: () => R;
onLoading: () => R;
onSuccess: (data: T) => R;
onFailure: (error: E) => R;
}
): R => {
switch (rd.status) {
case 'idle':
return matchers.onIdle();
case 'loading':
return matchers.onLoading();
case 'success':
return matchers.onSuccess(rd.data);
case 'failure':
return matchers.onFailure(rd.error);
default:
return assertNever(rd);
}
},
};Declaring a discriminant property with a generic primitive type like string instead of string literal types prevents the compiler from performing type narrowing.
// ❌ ANTIPATTERN: status has widened primitive type string
interface StateA { status: string; count: number; }
interface StateB { status: string; text: string; }
type MyState = StateA | StateB;
function handle(s: MyState) {
if (s.status === 'a') {
// ERROR: s does not narrow to StateA! Accessing s.count throws TS2339
console.log(s.count);
}
}
// ✅ CORRECT: Strict literal types
interface StateAGood { status: 'a'; count: number; }
interface StateBGood { status: 'b'; text: string; }
type MyStateGood = StateAGood | StateBGood;Using a bare break or an empty default: hides omissions when adding new union variants. The code builds without warnings, but unhandled states fail silently at runtime.
// ❌ ANTIPATTERN: Silently skipping unhandled variants
function processBad(state: OrderState) {
switch (state.status) {
case 'draft': return;
case 'paid': return;
// Omitted pending_payment, cancelled, fulfilled!
default:
break; // No compiler error, but runtime business logic is broken!
}
}
// ✅ CORRECT: assertNever triggers compile-time error
function processGood(state: OrderState) {
switch (state.status) {
case 'draft': return;
case 'pending_payment': return;
case 'paid': return;
case 'cancelled': return;
case 'fulfilled': return;
default:
return assertNever(state); // Build breaks until all variants are handled!
}
}If one member of a union uses type: 'A' while another uses kind: 'B', TypeScript cannot establish a unified property key to narrow the union with a single switch (item.type).
// ❌ ANTIPATTERN: Inconsistent tag property names
type Event =
| { type: 'click'; x: number }
| { kind: 'hover'; y: number }; // Divergent property keys!
// ✅ CORRECT: Uniform discriminant property name
type DomainEvent =
| { type: 'click'; x: number }
| { type: 'hover'; y: number };Calling assertNever(state as any) disables static verification, nullifying the compile-time safety guard.
// ❌ ANTIPATTERN: Bypassing type checker with any
default:
return assertNever(state as any); // Compiler silenced, bugs leak into production!Enumerate all valid phases of an entity and eliminate duplicate boolean flags (isLoading, isError, hasData).
Define a uniform string or numeric literal property (status / type / kind) across all variant interfaces.
Deploy an assertNever(x: never): never helper into the core utility layer.
Replace ad-hoc if-else checks with switch statements whose default branches invoke assertNever.
Add a stub variant to the union and verify that CI/CD and tsc block the build until all handlers are updated.