Discriminated Unions in TypeScript: Make Illegal States Unrepresentable
Replace boolean state bags with TypeScript discriminated unions—exhaustiveness, Result types, uploads, auth, checkout, and props that cannot be mis-held.
This article is published in English.
Sources: Discriminated Unions: The TypeScript Feature That Makes Bugs Impossible
Sometimes a profile view paints both a pending spinner and a crimson failure banner together.
Nobody shipped a SpinnerWithError component on purpose. The shape of the state type quietly allowed that combination, and a flaky connection made it visible.
This piece is about the modelling habit that permits those screens, and the TypeScript tool that pushes them out of the representable set: the discriminated union (also called a tagged union or algebraic data type in other languages).
A useful refresher from the TypeScript handbook: a shared field with literal types lets the checker narrow which member of a union is currently true.
Part 1: The Problem
One type, many shapes
Almost every React codebase has owned a state bag like this:
interface UserState {
isLoading: boolean;
user?: User;
error?: Error;
}
Count the combinations. isLoading is true or false; user present or absent; error present or absent. That is eight expressible shapes. The product only needs about three:
- Loading, no user, no error
- Finished, user present, no error
- Finished, no user, error present
The other five are nonsense—loading with an error, success and error together, finished with nothing at all, and so on.
The restaurant analogy
Imagine one universal order ticket where every field is optional: table number, pickup time, delivery address, courier name. Staff usually fill it correctly from habit. The form still allows “table 7” and “deliver to 12 Oak Street” on the same slip.
Optional properties model absence—this field might be blank—when you needed alternatives: this is either dine-in or delivery, never both.
How the smell shows up
You can spot it without reading business rules.
Non-null assertions. The check was three lines up; the compiler did not follow.
if (!state.isLoading && !state.error) {
return <Profile user={state.user!} />; // "trust me"
}
Every ! is an unenforced promise. It holds until someone edits the condition above.
Branches that can never run.
if (state.user) {
return <Profile user={state.user} />;
} else {
return <p>Something went wrong</p>; // dead code you still had to write, and now have to maintain
}
Boolean inflation. Requirements grow; someone adds another flag, then another.
interface UploadState {
isUploading: boolean;
isProcessing: boolean;
isComplete: boolean;
hasFailed: boolean;
isCancelled: boolean;
}
Five booleans mean thirty-two combinations for roughly five real modes. It is the hotel room with five unlabelled switches: you can set them all, and most settings do nothing useful.
Rules that live only in people’s heads. “Check error first, then user, then loading.” True in the first component; optional everywhere else.
Bugs that appear on bad networks. A failure sets error. A retry sets user. Nobody clears error, because clearing is a separate step nobody owns.
The boolean bag is attractive because it maps cleanly onto form fields and Redux-style patches. The cost is combinatorial: every new flag multiplies the illegal space. Discriminated unions invert that tradeoff—adding a state adds one named alternative instead of doubling the grid.
Part 2: The fix
Describe states, not fields
Model the same feature as alternatives:
type UserState =
| { status: 'loading' }
| { status: 'success'; user: User }
| { status: 'error'; error: Error };
Three states—exactly three. { status: 'loading', error: e } becomes a compile error. user does not exist until the success branch proves it.
Back to the restaurant: print three tickets labelled DINE-IN, TAKEAWAY, and DELIVERY. Each ticket only carries the fields that ticket needs. That label is the discriminant—a property typed as a string literal, not string.
Three ingredients make the pattern work:
- A union of object types (the alternatives)
- A shared property with literal types (the tag)
- Checking that property so TypeScript can pick the active member
Point 3 is narrowing:
function render(state: UserState) {
switch (state.status) {
case 'loading':
return <Spinner />;
case 'success':
return <Profile user={state.user} />; // user is User. Not User | undefined.
case 'error':
return <ErrorBanner error={state.error} />; // and here, user doesn't exist at all
}
}
No !. No dead branch. Inside each case, state has a different type deduced from a string comparison.
The part that pays the rent
Narrowing feels pleasant. Exhaustiveness is why the pattern sticks for years.
Add a small helper:
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
Wire it into default:
switch (state.status) {
case 'loading': return <Spinner />;
case 'success': return <Profile user={state.user} />;
case 'error': return <ErrorBanner error={state.error} />;
default: return assertNever(state);
}
never has no inhabitants. After every case is handled, state has been narrowed to nothing, so assertNever only typechecks when the switch is complete.
When product asks for a logged-out state:
type UserState =
| { status: 'loading' }
| { status: 'success'; user: User }
| { status: 'error'; error: Error }
| { status: 'unauthenticated'; loginUrl: string }; // new
Every switch that ignores the new member stops compiling—with a file and line—not “might misbehave.”
Think of a vending machine that gains a new coin. A poor machine swallows it silently. A good one rejects it at every slot that has not been taught the new denomination.
Prefer the error on the
switchitself? Annotate the return type (function render(state: UserState): ReactNode). Leaving a case out then fails the function signature without needingassertNever.
The principle underneath
Probe every type with this question:
How many inhabitants of this type describe situations that cannot actually happen?
Each illegal value is a future bug, a defensive check, and a test nobody writes. Drive that count toward zero—make illegal states unrepresentable.
Part 3: Where this pays off in real code
Not toy shapes—places the pattern earns its keep.
1. Loading data (including the states everyone forgets)
Do it properly. Two states often get skipped: refreshing while stale data stays visible, and failing without discarding what you already have.
type RemoteData<T, E = ApiError> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'refreshing'; data: T } // stale-while-revalidate
| { status: 'success'; data: T; fetchedAt: number }
| { status: 'failed'; error: E; lastData?: T }; // failed, but the page still has content
The actions that drive it are a discriminated union too—as is every reducer you have written:
type Action<T, E> =
| { type: 'fetch' }
| { type: 'resolved'; data: T }
| { type: 'rejected'; error: E };
function reducer<T, E>(state: RemoteData<T, E>, action: Action<T, E>): RemoteData<T, E> {
switch (action.type) {
case 'fetch':
return 'data' in state
? { status: 'refreshing', data: state.data }
: { status: 'loading' };
case 'resolved':
return { status: 'success', data: action.data, fetchedAt: Date.now() };
case 'rejected':
return { status: 'failed', error: action.error, lastData: 'data' in state ? state.data : undefined };
default:
return assertNever(action);
}
}
useReducer, Redux Toolkit, XState, event sourcing—same pattern under different labels.
2. Errors you can actually act on
Thrown exceptions are invisible to types. A throwing function looks like a quiet one; catch (e) yields unknown; callers cannot see failure modes.
A Result type is the courier note that explains what happened, instead of driving off and maybe telling the depot later:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
Boolean literals work as discriminants and read cleanly here.
The larger win is typed failures instead of a shrug Error:
type FetchFailure =
| { kind: 'offline' }
| { kind: 'timeout'; afterMs: number }
| { kind: 'http'; status: number; body: unknown }
| { kind: 'invalid_response'; issues: string[] }
| { kind: 'aborted' };
async function getInvoice(id: string): Promise<Result<Invoice, FetchFailure>> { /* ... */ }
UI can respond specifically, and the compiler insists every kind is considered:
const result = await getInvoice(id);
if (!result.ok) {
switch (result.error.kind) {
case 'offline': return toast('You seem to be offline.');
case 'timeout': return toast(`The server took over ${result.error.afterMs}ms. Try again?`);
case 'http': return result.error.status === 404 ? notFound() : serverError();
case 'invalid_response': return reportToSentry(result.error.issues);
case 'aborted': return; // user navigated away; say nothing
default: return assertNever(result.error);
}
}
renderInvoice(result.value); // narrowed to the success branch
The alternative is catch (e) { toast('Something went wrong') }—what codebases write when types stay out of the story.
3. Uploads, where each state carries its own buttons
Union members can carry different functions, not only data—so an operation can be unavailable in some states by construction:
type Upload =
| { status: 'queued'; file: File; cancel(): void }
| { status: 'uploading'; file: File; progress: number; cancel(): void }
| { status: 'scanning'; file: File } // too late to cancel
| { status: 'done'; file: File; url: string; remove(): Promise<void> }
| { status: 'failed'; file: File; reason: string; retry(): void };
You cannot cancel a pizza already in the oven, so cancel is absent from that ticket. upload.retry() will not compile until you have narrowed to a state that owns retry.
4. Auth, and the flash of the wrong UI
null collapsed two different situations: we do not know yet and nobody is signed in.
type Session =
| { status: 'initializing' } // ≠ logged out
| { status: 'anonymous' }
| { status: 'active'; user: User; accessToken: string; expiresAt: number }
| { status: 'expiring'; user: User; refresh(): Promise<void> }
| { status: 'locked'; user: User; reason: 'mfa_required' | 'password_reset' };
Split them and the wrong flash becomes unwritable.
5. Multi-step flows, where the type remembers
In checkout, each step needs what earlier steps produced. One big object with everything optional forces address! gymnastics.
Let each step carry what it knows:
type Checkout =
| { step: 'cart'; items: CartItem[] }
| { step: 'shipping'; items: CartItem[]; address: Address }
| { step: 'payment'; items: CartItem[]; address: Address; shipping: ShippingOption }
| { step: 'review'; items: CartItem[]; address: Address; shipping: ShippingOption; payment: PaymentMethod }
| { step: 'confirmed'; orderId: string; total: Money; receiptUrl: string };
Reaching payment without an address is structurally impossible. A payment screen takes one union member as props:
type PaymentStepProps = Extract<Checkout, { step: 'payment' }>;
Extract pulls a member by tag—constant once components are typed this way.
6. Things that share a name and nothing else
Payment methods share almost no fields. One interface yields fifteen optionals and validation full of if (type === ...).
type PaymentMethod =
| { type: 'card'; brand: 'visa' | 'mastercard' | 'amex'; last4: string; expMonth: number; expYear: number }
| { type: 'paypal'; email: string }
| { type: 'sepa_debit'; iban: string; mandateId: string }
| { type: 'apple_pay'; deviceAccount: string }
| { type: 'invoice'; companyName: string; vatId: string; netDays: 14 | 30 | 60 };
function describe(method: PaymentMethod): string {
switch (method.type) {
case 'card': return `${method.brand.toUpperCase()} •••• ${method.last4}`;
case 'paypal': return `PayPal (${method.email})`;
case 'sepa_debit': return `SEPA •••• ${method.iban.slice(-4)}`;
case 'apple_pay': return 'Apple Pay';
case 'invoice': return `Invoice, net ${method.netDays} days`;
default: return assertNever(method);
}
}
When Klarna arrives, one build surfaces the icon map, receipt renderer, refund path, and admin table—together.
7. Messages from the outside world
Sockets, SSE, postMessage, extensions—payloads are unknown until proven:
type ServerMessage =
| { type: 'presence.join'; userId: string; at: number }
| { type: 'presence.leave'; userId: string; at: number }
| { type: 'cursor.move'; userId: string; x: number; y: number }
| { type: 'doc.patch'; revision: number; ops: Op[] }
| { type: 'doc.conflict'; serverRevision: number; snapshot: Doc }
| { type: 'error'; code: 'RATE_LIMITED' | 'UNAUTHORIZED' | 'STALE_REVISION'; retryAfterMs?: number };
Discipline: validate once at the border, then trust inward. Passport control, not ID checks in every corridor. Zod’s discriminated helper fits:
const ServerMessage = z.discriminatedUnion('type', [
z.object({ type: z.literal('presence.join'), userId: z.string(), at: z.number() }),
// ...
]);
socket.onmessage = (e) => {
const parsed = ServerMessage.safeParse(JSON.parse(e.data));
if (!parsed.success) return reportProtocolViolation(parsed.error);
handle(parsed.data); // fully typed from here on
};
8. Documents and trees
Unions can be recursive—the natural shape for rich text, page builders, form defs, query ASTs:
type Block =
| { type: 'paragraph'; children: Inline[] }
| { type: 'heading'; level: 1 | 2 | 3; children: Inline[] }
| { type: 'list'; ordered: boolean; items: Block[][] }
| { type: 'image'; src: string; alt: string; caption?: string }
| { type: 'code'; language: string; source: string };
type Inline =
| { type: 'text'; value: string; marks?: ('bold' | 'italic' | 'code')[] }
| { type: 'link'; href: string; children: Inline[] }
| { type: 'mention'; userId: string };
function renderBlock(block: Block): ReactNode {
switch (block.type) {
case 'paragraph': return <p>{block.children.map(renderInline)}</p>;
case 'heading': return createElement(`h${block.level}`, null, block.children.map(renderInline));
case 'list': return block.ordered ? <ol>{/* ... */}</ol> : <ul>{/* ... */}</ul>;
case 'image': return <figure><img src={block.src} alt={block.alt} /></figure>;
case 'code': return <Highlight lang={block.language}>{block.source}</Highlight>;
default: return assertNever(block);
}
}
Add a table block and the checker lists renderer, serializer, plain-text extract, word count, and search index.
9. Component props that can’t be held wrong
Unions express “if you pass this, you must pass that, and must not pass the other”:
type ButtonProps = { children: ReactNode; size?: 'sm' | 'md' | 'lg' } & (
| { as: 'button'; onClick: () => void; type?: 'button' | 'submit' }
| { as: 'anchor'; href: string; target?: '_blank'; download?: boolean }
);
<Button as="anchor" onClick={fn} /> // bad: anchors don't take onClick
<Button as="anchor" /> // bad: href is required
<Button as="button" onClick={fn} /> // good
The same idea kills familiar bugs:
// No more "a component is changing an uncontrolled input to be controlled"
type InputProps =
| { mode: 'controlled'; value: string; onChange: (v: string) => void }
| { mode: 'uncontrolled'; defaultValue?: string };
// An icon-only button must have a label; a text button must not repeat one
type IconButtonProps =
| { icon: IconName; 'aria-label': string; children?: never }
| { icon?: IconName; children: ReactNode };
The second enforces an accessibility rule at compile time (children?: never forbids a prop on one branch).
And a dozen more, briefly
After the idea lands, you notice it in route objects ({ name: 'order'; orderId: string } rather than concatenated path strings), flag enums, toast variants, form field kinds, permission grants, animation phases, and similar catalogs.
Part 4: Couple more techniques
Narrowing is not only switch.
if (state.status === 'success') { /* narrowed */ }
const label = state.status === 'error' ? state.error.message : '';
// Destructuring narrows the sibling variables too (TS 4.6+)
function handle({ kind, payload }: { kind: 'add'; payload: number } | { kind: 'set'; payload: string }) {
if (kind === 'add') return payload.toFixed(2); // payload is number
return payload.toUpperCase(); // payload is string
}
That works for parameters and const bindings. Grabbing a rest object does not: const { kind, ...rest } = shape leaves rest too wide.
// switch (true) narrows as well (TS 5.3+) — handy for mixed conditions
function view(state: RemoteData<Report>) {
switch (true) {
case state.status === 'success' && isStale(state.fetchedAt):
return <Stale data={state.data} />;
case state.status === 'success':
return <Fresh data={state.data} />;
default:
return <Spinner />;
}
}
A lookup object instead of a switch. Often clearer, and still exhaustive—omit a key and it fails to compile:
type Handlers = {
[K in ServerMessage['type']]: (msg: Extract<ServerMessage, { type: K }>) => void;
};
const handlers: Handlers = {
'presence.join': (m) => addUser(m.userId),
'presence.leave': (m) => removeUser(m.userId),
'cursor.move': (m) => moveCursor(m.userId, m.x, m.y),
'doc.patch': (m) => applyOps(m.ops),
'doc.conflict': (m) => reconcile(m.snapshot),
'error': (m) => showError(m.code),
// all six keys are mandatory — that's the point
};
There is a catch. The obvious call does not typecheck:
handlers[msg.type](msg); // bad: Argument of type 'ServerMessage' is not assignable to parameter of type 'never'
TypeScript treats handlers[msg.type] as a union of functions and demands an argument acceptable to all of them—the intersection—which is empty for distinct message shapes.
Localise one cast at the dispatcher:
// Localise one cast at the dispatcher
function dispatch(msg: ServerMessage) {
(handlers[msg.type] as (m: ServerMessage) => void)(msg);
}
// Or don't build a dispatcher at all — call the handlers from a switch, no casts anywhere
switch (msg.type) {
case 'presence.join': return handlers['presence.join'](msg);
case 'cursor.move': return handlers['cursor.move'](msg);
// ...
}
Know this before refactoring a working switch into a map.
Small constructors keep call sites tidy:
const ok = <T,>(value: T) => ({ ok: true, value }) as const;
const err = <E,>(error: E) => ({ ok: false, error }) as const;
Multiple tags compose:
type Notification =
| { channel: 'email'; kind: 'digest'; frequency: 'daily' | 'weekly' }
| { channel: 'email'; kind: 'transactional'; templateId: string }
| { channel: 'push'; kind: 'transactional'; deviceToken: string };
if (n.channel === 'email' && n.kind === 'digest') { /* down to one member */ }
Part 5: The things worth remembering
The tag must be a literal type. This is the top stumbling block; the error message confuses newcomers:
const action = { type: 'fetch' }; // inferred as { type: string } — not assignable
const action = { type: 'fetch' } as const; // ok
const action: Action = { type: 'fetch' }; // ok. an annotation also prevents widening
Prefer type, not interface. An interface cannot be a union. Here the choice matters.
Narrowing can evaporate inside callbacks. It does survive into a nested function when the binding is a const parameter that is never reassigned:
function ok(state: UserState) {
if (state.status === 'success') {
setTimeout(() => console.log(state.user), 0); // fine, state is never reassigned
}
}
Two patterns undo the guarantee. Assigning a new value to the binding anywhere in the enclosing function—even after the nested callback—wipes narrowing for the whole body:
function broken(input: UserState, other: UserState) {
let state = input;
if (state.status === 'success') {
setTimeout(() => console.log(state.user), 0); // user does not exist on all members
}
state = other; // this line is why
}
Property-level narrowing also stops at nested functions: the checker cannot prove the object was not mutated in between:
if (box.state.status === 'success') {
setTimeout(() => console.log(box.state.user), 0); // bad
}
In both cases, copy the needed fields into a local const before scheduling work:
if (box.state.status === 'success') {
const { user } = box.state;
setTimeout(() => console.log(user), 0); // good
}
Lean on the discriminant rather than in checks. An 'data' in state test can work, yet it is only a structural guess and fails when two variants share the same property name. An explicit tag states the intent.
Standardize tag names. Use one vocabulary across the codebase—for example type on events, status on async lifecycles, kind on failure variants—so helpers such as Extract stay scannable. Mixing labels across domains makes those helpers harder to read.
Skip the tag when it adds nothing. When variants share the same fields and differ only by a label, a simple string union is enough—for example type Size = 'sm' | 'md' | 'lg'—without wrapping each label in its own object.
Watch compile-time cost. Extremely large unions, especially after mapping or intersecting them, can make the type checker noticeably slower. Carve the model by product domain before the editor lag becomes a habit.
Types do not validate input. Casting JSON.parse(x) as ServerMessage never inspects the payload. Parse and check at the edge of the system, then let inward code trust the narrowed shapes.
Before adopting the pattern everywhere, also remember that documentation and runtime telemetry still matter: types block illegal states in the editor, but operators still need logs when a network boundary rejects a payload.
When to reach for it
Prefer a tagged union when daily coding shows these smells:
- an interface packs fields that are only meaningful as a group, not independently
- another boolean lands on an object that already carries a flag
- non-null assertions or casts appear just to quiet the checker about a field
- comments explain which combinations are legal because the type cannot
- onboarding includes a verbal checklist of which flags may coexist
Migration is usually mechanical: list the states the system is truly in, name each one, move each field onto the states where it exists, then follow the compile errors. Walk that error list slowly—it charts every place the old type was lying, and the list is almost always longer than expected. Once those switches compile again, the spinner-plus-error screenshot stops being a representable UI state.