Home / Articles

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.

Publisher: REACTAPP.TOP16 min read3622 words

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:

  1. Loading, no user, no error
  2. Finished, user present, no error
  3. 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:

  1. A union of object types (the alternatives)
  2. A shared property with literal types (the tag)
  3. 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 switch itself? Annotate the return type (function render(state: UserState): ReactNode). Leaving a case out then fails the function signature without needing assertNever.

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.