JS / TS

TypeScript Discriminated Unions: The Pattern That Eliminates a Whole Category of Bugs

3 min read

Every frontend codebase has a component that juggles loading, success, and error state — and most of them do it with a handful of independent variables that can silently contradict each other. Discriminated unions are TypeScript's answer, and once you adopt the pattern, an entire category of bug ("the spinner and the error showed at once") stops being possible. Not unlikely. Impossible — the compiler refuses to build it.

The problem: state that can lie

The standard data-fetching setup:

// ❌ Three independent facts that can disagree
let isLoading: boolean;
let data: User[] | undefined;
let error: string | undefined;

Count the combinations: two booleans times two optionals gives eight representable states — and only three of them are legitimate. isLoading: true with data present? error set alongside data? Nothing prevents these, so every consumer defensively re-checks everything:

if (!isLoading && !error && data) {
  render(data); // and you'd better repeat this dance everywhere
}

The undefined-checks aren't the disease; they're the symptom. The disease is that the type allows states that can't happen.

What a discriminated union actually is

A union of object shapes where every member carries the same literal-typed property — the discriminant — identifying which shape it is:

type FetchState =
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; message: string };

Read what this encodes: data doesn't exist at all unless status is 'success'. There is no way to write a value of this type where loading and error coexist, or where data is present mid-load. The illegal states aren't checked against — they're unrepresentable.

TypeScript narrows on the discriminant

Because the discriminant is a literal type, checking it teaches the compiler the whole shape — the same narrowing machinery that powers typeof checks:

function render(state: FetchState) {
  switch (state.status) {
    case 'loading':
      return <Spinner />;
    case 'error':
      return <Alert message={state.message} />; // message exists — guaranteed
    case 'success':
      return <UserList users={state.data} />; // data exists — guaranteed
  }
}

No optional chaining, no defensive re-checks, no ! assertions. Each branch's body is checked against exactly the variant that can reach it. And there's a bonus: add a fourth variant ({ status: 'refreshing'; data: User[] }) and every non-exhaustive switch in the codebase becomes a compile error — the compiler hands you the complete list of places that need updating. That's a refactoring safety net you can't get from booleans.

Before and after, in one component

// Before: 8 representable states, 3 legitimate, 5 bugs waiting
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState<User[]>();
const [error, setError] = useState<string>();
 
// After: 3 representable states, 3 legitimate, 0 contradictions possible
const [state, setState] = useState<FetchState>({ status: 'loading' });
 
// Transitions become atomic — no "forgot to clear the error" bugs:
setState({ status: 'success', data: users }); // one call, consistent by construction

The atomic-transition point is underrated: with separate variables, every transition is a multi-step ritual (setError(undefined); setIsLoading(false); setData(users)) where forgetting one line creates a lying UI. With the union, a transition is one assignment and cannot be partial.

Where else the pattern earns its keep

  • Form state: { status: 'editing' } | { status: 'submitting' } | { status: 'submitted'; receipt: Receipt } | { status: 'invalid'; errors: FieldErrors } — the submit button's disabled logic falls out of the discriminant.
  • API response types: model your backend's envelope as { ok: true; data: T } | { ok: false; error: ApiError } and error-handling stops being optional at compile time.
  • Anything currently using two booleans in a trench coatisOpen/isClosing, isDraft/isPublished. If certain combinations are meaningless, that's the signal.

The design principle underneath is worth stating plainly: make illegal states unrepresentable. It's the cheapest bug prevention TypeScript offers — no runtime cost, no library, just modeling state as what it actually is: one thing at a time.

Found this useful?

Share

I post shorter takes on javascript & typescript and frontend leadership on LinkedIn — follow along there, or get in touch if you're working on something related.