ESC

Type to search the knowledge base.

Discriminated Unions for UI State

Model loading, success, and error as mutually exclusive variants so TypeScript and your UI cannot show impossible states.

intermediate3 min read
  • typescript
  • discriminated-unions

UI state goes wrong when fields that should never coexist all sit optional on one object:

type Bad = {
  loading: boolean;
  error?: string;
  data?: User;
};
// loading + data + error all true-ish — valid type, invalid product

A discriminated union (tagged union) makes variants exclusive. The discriminant field tells TypeScript which properties exist.

Docs: Narrowing — Discriminated unions, Type narrowing.

The pattern

type RemoteData<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function UserCard({ state }: { state: RemoteData<User> }) {
  switch (state.status) {
    case 'idle':
      return <p>Pick a user</p>;
    case 'loading':
      return <p role="status">Loading…</p>;
    case 'success':
      return <h2>{state.data.name}</h2>; // data guaranteed
    case 'error':
      return <p role="alert">{state.error.message}</p>;
  }
}

status is the discriminant. After case 'success', state.data exists; state.error does not.

Why not booleans + optionals?

// Ambiguous
if (!loading && data) { /* success? or stale data during error? */ }

Teams invent informal rules (“ignore data when error is set”). Compilers don’t enforce folklore. Discriminants do.

Async reducer / useState

type Action<T> =
  | { type: 'fetch' }
  | { type: 'success'; data: T }
  | { type: 'failure'; error: Error }
  | { type: 'reset' };

function reduce<T>(state: RemoteData<T>, action: Action<T>): RemoteData<T> {
  switch (action.type) {
    case 'fetch':
      return { status: 'loading' };
    case 'success':
      return { status: 'success', data: action.data };
    case 'failure':
      return { status: 'error', error: action.error };
    case 'reset':
      return { status: 'idle' };
  }
}

Same idea for forms: 'editing' | 'submitting' | 'submitted' | 'invalid'.

Multi-field discriminants

Prefer one tag. Nested exclusivity:

type ModalState =
  | { open: false }
  | { open: true; title: string; mode: 'create' | 'edit'; id?: string };

When open: false, no title required — the closed modal can’t be half-configured.

Exhaustiveness

Pair with never so new variants fail compile:

function assertNever(x: never): never {
  throw new Error(`unexpected: ${JSON.stringify(x)}`);
}

function label(state: RemoteData<unknown>): string {
  switch (state.status) {
    case 'idle':
      return 'Idle';
    case 'loading':
      return 'Loading';
    case 'success':
      return 'OK';
    case 'error':
      return 'Error';
    default:
      return assertNever(state);
  }
}

See never type and exhaustiveness.

React Query / SWR mapping

External libraries already expose status-like APIs. Map them once at the boundary:

function toRemote<T>(q: {
  isPending: boolean;
  isError: boolean;
  isSuccess: boolean;
  data: T | undefined;
  error: Error | null;
}): RemoteData<T> {
  if (q.isPending) return { status: 'loading' };
  if (q.isError) return { status: 'error', error: q.error ?? new Error('unknown') };
  if (q.isSuccess && q.data !== undefined) return { status: 'success', data: q.data };
  return { status: 'idle' };
}

Don’t re-derive loading && !error && data ad hoc in every component.

Serialization note

JSON preserves the tag. Prefer string discriminants over symbols. On the wire, validate with a schema before trusting status.

Footguns

Footgun Fix
Optional data on every variant Put data only on success
Boolean isX flags beside a tag One source of truth
Discriminant not a literal type Use string literal union, not string
Forgetting default/never Exhaustiveness breaks silently
// Weak discriminant
type W = { status: string; data?: User }; // status is any string

Interview out-loud answer

“I model async UI as a discriminated union with a status tag so success data and errors can’t coexist. Switch on the tag; TypeScript narrows. I use never in the default branch for exhaustiveness when we add states later.”

Further reading

Related guides