Skip to content

Discriminated Unions in TypeScript: Modeling State

Replacing Boolean Soup With Discriminated Unions

isLoading, isError, and data as three separate booleans let you represent impossible states. A discriminated union makes those states unrepresentable.

· · 4 min read

Quick Take

I inherited a component with isLoading, isError, and data as three independent booleans, which meant isLoading and isError could both be true at once, a state that made no sense and happened anyway.

The bug report was simple: a spinner and an error message showing at the same time. The cause was three independent booleans on a fetch state object, isLoading, isError, isSuccess, none of which prevented the other two from also being true. TypeScript didn't catch it because nothing about that shape was actually invalid, to the compiler.

TL;DR: Three independent booleans (isLoading, isError, data) can represent 2^3 combinations when only 3 or 4 are ever valid, and TypeScript can't stop the invalid ones. A discriminated union, one status property with distinct literal values per state, makes the impossible combinations impossible to construct, and TypeScript narrows the rest of the object's shape automatically based on which branch of the union you're in.

The Boolean Soup Version

interface FetchState<T> {
  isLoading: boolean;
  isError: boolean;
  error?: string;
  data?: T;
}

function ProductList({ state }: { state: FetchState<Product[]> }) {
  if (state.isLoading) {return <Spinner />;}
  if (state.isError) {return <ErrorMessage message={state.error} />;}
  // TypeScript still thinks state.data might be undefined here,
  // nothing in the type actually guarantees it exists at this point
  return <ul>{state.data!.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

That state.data! non-null assertion is the tell. The type system genuinely can't guarantee data exists just because isLoading and isError are both false, because nothing links those three fields together. You, the developer, know the intended relationship. TypeScript doesn't.

The Discriminated Union Version

type FetchState<T> =
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: T };

function ProductList({ state }: { state: FetchState<Product[]> }) {
  if (state.status === 'loading') {return <Spinner />;}
  if (state.status === 'error') {return <ErrorMessage message={state.error} />;}
  // TypeScript now KNOWS state.data exists here, no assertion needed
  return <ul>{state.data.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

No !. state.status === 'success' is the discriminant check, and TypeScript narrows state's type to the third union member on that branch, where data is guaranteed to exist and error doesn't. There's also no longer a way to construct { status: 'loading', error: 'oops' }, that object doesn't match any member of the union, so TypeScript rejects it at the point of creation, not three components downstream when someone reads a stale error field that shouldn't be there.

Exhaustiveness Checking With switch

Discriminated unions pair naturally with switch, and TypeScript can verify you've handled every case:

function renderState(state: FetchState<Product[]>) {
  switch (state.status) {
    case 'loading':
      return <Spinner />;
    case 'error':
      return <ErrorMessage message={state.error} />;
    case 'success':
      return <ProductGrid products={state.data} />;
    default: {
      const _exhaustive: never = state; // compile error if a case is missing
      throw new Error(`Unhandled status: ${(_exhaustive as FetchState<unknown>).status}`);
    }
  }
}

The const _exhaustive: never = state line is the trick: if you add a fourth union member, { status: 'idle' }, later, and forget to add a case 'idle': branch, state's narrowed type inside default is no longer never, it's the unhandled member, and the assignment to never fails to compile. That's a compile-time reminder to handle the new case, exactly where you'd otherwise discover the gap at runtime instead.

A Second Example: Form Submission State

type SubmitState =
  | { status: 'idle' }
  | { status: 'submitting' }
  | { status: 'success'; confirmationId: string }
  | { status: 'failed'; reason: string; canRetry: boolean };

Four states, each with only the fields that are actually meaningful for it. confirmationId doesn't exist unless status is 'success', canRetry doesn't exist unless it 'failed'. Compare that to a flat interface with isSubmitting, isSuccess, isFailed, confirmationId?, reason?, canRetry?, six fields where every optional one needs a manual mental note about which combination of booleans makes it meaningful.

When a Plain Boolean Is Still Fine

Not every boolean pair is boolean soup. A single, truly independent flag, disabled: boolean on a button, doesn't need a union, there's no other state it's entangled with. The pattern applies specifically when two or more flags describe mutually exclusive phases of the same process, loading versus error versus success are phases of one fetch, not independent facts that happen to co-occur.

Old Way vs Modern Way

Boolean flagsDiscriminated union
Invalid combinationsCompile fine, must be prevented by disciplineDon't exist as constructible values
Accessing state-specific fieldsRequires ! assertions or manual checksAutomatically narrowed, no assertion needed
Adding a new stateAdd another boolean, update every check siteAdd a union member, switch exhaustiveness flags missed handling
ReadabilitySix-plus loosely related fieldsOne tagged shape per state

Conclusion

The bug that started this, a spinner and an error rendering together, wasn't a logic mistake in one component, it was a type shape that allowed an invalid state to exist in the first place. Discriminated unions don't just organize the code better, they remove the invalid combination from the set of values the type system will accept, which is a stronger guarantee than any amount of careful boolean-setting discipline.

Frequently Asked Questions

What makes a union 'discriminated' in TypeScript?
A discriminated union is a union of object types that share a common property, the discriminant, or tag, where each member has a different literal value for that property. TypeScript uses the discriminant to narrow the type automatically inside an if or switch that checks it, so once you check status === 'success', TypeScript knows the data property exists on that branch and error doesn't, without a separate type assertion.
Why is 'boolean soup' a real bug risk and not just a style preference?
Three independent booleans, isLoading, isError, isSuccess, describe 2 cubed, eight possible combinations, when only 3 or 4 are ever meant to be valid. Nothing in the type system stops isLoading and isError from both being true simultaneously, that state compiles fine and has to be prevented by discipline in every place the state is set, which inevitably slips at some point in a large codebase. A discriminated union makes the invalid combinations not exist as a representable value at all.
Does a discriminated union have runtime cost compared to separate booleans?
No. Discriminated unions are a compile-time-only construct, the discriminant property (often a string literal like status: 'success') is a completely normal object property at runtime, with no special TypeScript machinery generating extra code. The type safety is free, it costs nothing at runtime beyond the one string property you were likely already storing in some form.