Most state bugs I've debugged in production came from state shapes that allowed combinations that should never have existed. isLoading: true and data: [...] set at the same time. error populated while isSuccess is also true. Nobody wrote that state on purpose — the type system just let it happen, and eventually some code path produced it. Discriminated unions fix this at the type level, not the review-checklist level.

The boolean-soup problem

A typical first pass at async state looks like this:

typescript
interface RequestState<T> {
  isLoading: boolean;
  isError: boolean;
  data: T | null;
  error: string | null;
}

Nothing here prevents { isLoading: true, isError: true, data: someData, error: null }. That's four independent fields describing what should be one mutually exclusive state, and the number of technically-valid-but-nonsensical combinations grows fast as you add fields. Every consumer of this state has to defensively check combinations that shouldn't exist.

Modeling it as a union instead

A discriminated union uses a single literal field — the "tag" or "discriminant" — to say which variant you're in, and each variant only carries the fields relevant to it.

typescript
type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

function render<T>(state: RequestState<T>) {
  switch (state.status) {
    case "idle":
      return "Waiting to start";
    case "loading":
      return "Loading…";
    case "success":
      return state.data; // data only exists here — no null check needed
    case "error":
      return state.error; // same for error
  }
}

Inside the "success" branch, state.data is guaranteed to exist because TypeScript narrows the union based on the status check. You never write state.data! or a null guard you're not sure is load-bearing. The type system is doing the defensive programming for you.

Exhaustiveness checking catches the forgotten case

The real payoff shows up when you add a new variant later — say a "canceled" status for aborted requests. Without exhaustiveness checking, it's easy to forget to handle it somewhere in a large codebase. Assign the unhandled case to never in a default branch, and the compiler tells you exactly where you missed it:

typescript
function label(state: RequestState<unknown>): string {
  switch (state.status) {
    case "idle": return "Idle";
    case "loading": return "Loading";
    case "success": return "Success";
    case "error": return "Error";
    default: {
      const _exhaustive: never = state;
      return _exhaustive;
    }
  }
}

Add "canceled" to the union and forget to handle it here, and state in the default branch is no longer assignable to never — you get a compile error at the exact spot that needs updating, instead of a runtime undefined falling through.

The same pattern applies to reducers

Redux-style reducers and useReducer are just discriminated unions on the action side. Give every action a literal type field and a payload shape specific to that action, and the reducer's switch statement gets the same narrowing and exhaustiveness benefits:

typescript
type CartAction =
  | { type: "add"; itemId: string; quantity: number }
  | { type: "remove"; itemId: string }
  | { type: "clear" };

You can't accidentally read action.itemId on a "clear" action — it doesn't type-check. That's not a linting convention; it's structurally impossible, which is exactly the property you want from state that other people will maintain after you.