All posts
JavaScript & TypeScript··9 min read

Discriminated Unions: The TypeScript Pattern I Reach For Most

Discriminated unions turn impossible states into compile errors. The single TypeScript pattern that has removed the most bugs from my code, with real examples.

By

On this page

The worst bug I shipped in 2024 was a loading spinner that never went away. The component had isLoading, data, and error all on the same state object. A retry path set isLoading: true without clearing the previous error, and a guard somewhere checked error before isLoading. The spinner spun forever while an old error sat underneath it, invisible. No type checker complained, because every one of those fields was independently valid. The combination was nonsense, but TypeScript had no way to know that.

That bug is the whole argument for discriminated unions. When you model state as a bag of optional booleans and nullable fields, you are telling the compiler that all 2^n combinations are legal. Most of them aren't. A discriminated union lets you describe the handful that are, and then the compiler enforces it for free.

This is the single TypeScript pattern that has removed the most bugs from my code over the last decade. Here is how it works and where I reach for it.

The shape of the pattern

A discriminated union (also called a tagged union or sum type) is a union of object types that all share one field — the discriminant — whose value is a different literal in each member.

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

The status field is the discriminant. It is a literal type — "idle", not string — in every member. That literal is what lets TypeScript narrow. When you check state.status === "success", the compiler knows you are in exactly one branch, so state.data becomes available and state.error does not exist. Try to read state.data in the "loading" branch and you get a compile error, not a runtime undefined.

Compare that to the bag-of-fields version:

// The shape that let my spinner spin forever.
interface BadFetchState<T> {
  isLoading: boolean;
  data?: T;
  error?: Error;
}

BadFetchState allows { isLoading: true, data: someData, error: someError }. Is that "loading", "succeeded", or "failed"? Nobody knows. The union version cannot represent that state at all. You have made the impossible state unrepresentable — the slogan you will see attributed to Yaron Minsky, and the reason this pattern matters.

Exhaustiveness: turning "I forgot a case" into a build break

Narrowing is half the value. The other half is exhaustiveness checking, and you have to opt into it deliberately.

Here is a renderer for the fetch state, with an exhaustive switch:

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
 
function render<T>(state: FetchState<T>): string {
  switch (state.status) {
    case "idle":
      return "Nothing yet.";
    case "loading":
      return "Loading…";
    case "success":
      return `Got ${JSON.stringify(state.data)}`;
    case "error":
      return `Failed: ${state.error.message}`;
    default:
      return assertNever(state);
  }
}

The assertNever helper is the load-bearing part. By the time control reaches default, TypeScript has narrowed state down through every handled case. If you have covered all four, the remaining type is never, and assertNever(state) type-checks. The moment someone adds a fifth member — say { status: "refreshing"; data: T } — the narrowed type in default is no longer never, and assertNever(state) fails to compile:

Argument of type '{ status: "refreshing"; data: T; }' is not
assignable to parameter of type 'never'.

That error is the whole point. Adding a state forces you to visit every switch that consumes it. I have shipped reducers with twelve action types where this caught a missed case in a code review I had already approved by eye. The compiler does not get tired on a Friday afternoon.

Two config notes that make this reliable:

  • Set "strict": true in tsconfig.json. Without strictNullChecks, narrowing is weaker and never inference gets sloppy.
  • Do not rely on ESLint's default-case rule for this. That rule wants a default; exhaustiveness wants the body of the default to be assertNever. They are different goals. The compiler is the real check.

If you would rather skip the runtime throw, you can make exhaustiveness purely a type-level assertion, but I keep the throw because it also protects against bad data crossing a network boundary at runtime — a status string the type system trusted but the server got wrong.

Modeling async state in React

The fetch-state union is not academic; it is how I write data-fetching components. Here is the before, the version that produced my forever-spinner:

function UserCard({ id }: { id: string }) {
  const [isLoading, setIsLoading] = useState(false);
  const [data, setData] = useState<User | null>(null);
  const [error, setError] = useState<Error | null>(null);
 
  // Three setters, called in different orders on different paths.
  // Nothing stops error and data from both being non-null.
  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  if (data) return <Profile user={data} />;
  return null; // the unreachable-looking branch that is actually reachable
}

Three useState calls, three setters, and an implicit ordering of if checks that encodes business rules nowhere written down. The return null at the bottom is the tell: you cannot convince yourself it is unreachable, because the state space is too big to reason about.

The after collapses it to one state value:

function UserCard({ id }: { id: string }) {
  const [state, setState] = useState<FetchState<User>>({ status: "idle" });
 
  useEffect(() => {
    let cancelled = false;
    setState({ status: "loading" });
    fetchUser(id)
      .then((user) => {
        if (!cancelled) setState({ status: "success", data: user });
      })
      .catch((err: unknown) => {
        if (!cancelled) {
          setState({ status: "error", error: toError(err) });
        }
      });
    return () => {
      cancelled = true;
    };
  }, [id]);
 
  switch (state.status) {
    case "idle":
    case "loading":
      return <Spinner />;
    case "success":
      return <Profile user={state.data} />;
    case "error":
      return <ErrorBanner message={state.error.message} />;
    default:
      return assertNever(state);
  }
}

Every transition is a single setState to a complete, valid state. There is no path that leaves error set while moving to loading, because { status: "loading" } has no error field to leave behind. The default: assertNever(state) means if I later add a "refreshing" state, this component will not compile until I decide what it renders.

Note toError(err: unknown): in TypeScript with useUnknownInCatchVariables (on by default under strict), caught values are unknown, so you normalize them before storing. That is its own small win the union nudges you toward.

Domain events and reducers

The same shape models domain events. Events are discriminated unions by nature — each has a type and a different payload.

type CartEvent =
  | { type: "item_added"; sku: string; quantity: number }
  | { type: "item_removed"; sku: string }
  | { type: "coupon_applied"; code: string; discount: number }
  | { type: "checkout_started"; total: number };
 
function reduce(state: CartState, event: CartEvent): CartState {
  switch (event.type) {
    case "item_added":
      return addItem(state, event.sku, event.quantity);
    case "item_removed":
      return removeItem(state, event.sku);
    case "coupon_applied":
      return applyCoupon(state, event.code, event.discount);
    case "checkout_started":
      return { ...state, checkout: { total: event.total } };
    default:
      return assertNever(event);
  }
}

This is exactly the Action type you would hand-write for useReducer, Redux Toolkit, or an event-sourced aggregate. In each case, event is narrowed to that member, so event.quantity exists in item_added and is a compile error in item_removed. When the product team adds gift_wrap_selected, the reducer breaks the build until you handle it — which is the behavior you want from a system of record.

The same union also models API responses cleanly. A server that returns either a result or a structured error is a two-member union, and your client code narrows on a ok discriminant instead of guessing whether data or error is populated.

{ "ok": true, "data": { "id": "u_123", "name": "Ada" } }
{ "ok": false, "error": { "code": "NOT_FOUND", "message": "no such user" } }
type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: { code: string; message: string } };

Here ok: true / ok: false is the discriminant. Boolean literals discriminate just as well as string literals, and the JSON maps one-to-one onto the type. Validate the wire payload with Zod or Valibot at the boundary, and from there inward the union carries the guarantee.

When I do not reach for it

Honest tradeoffs, because the pattern is not free.

SituationUse a discriminated union?
Mutually exclusive states (loading/success/error)Yes — this is the core use case
Events, actions, messages with distinct payloadsYes
Independent, orthogonal flags (isAdmin, isVerified)No — those genuinely combine; booleans are correct
Two states that share every fieldNo — a single optional field is simpler
Public API shape consumed by non-TS clientsYes, but document the discriminant explicitly

The trap is reaching for it on flags that are actually independent. A user can be both an admin and verified; those booleans are not mutually exclusive, so a union would be wrong and clumsy. Reserve unions for states that exclude each other. The test I use: "Can two of these be true at once?" If no, union. If yes, separate fields.

The other cost is a little verbosity. { status: "loading" } is more characters than flipping a boolean. Worth it. Every byte of that verbosity is the compiler doing your state-machine review.

One performance note for the curious: discriminated unions are a pure compile-time construct. They erase completely. The status field is a normal string at runtime; assertNever is the only code that survives, and it only runs if your data is genuinely corrupt. Zero runtime overhead for the type-level guarantees.

The checklist

When you next find yourself writing parallel booleans and nullable fields on one object, stop and run this:

  1. List the real states. Write them out. Loading, success, error — not isLoading, data, error.
  2. Pick a discriminant. status, type, kind, or a boolean ok. Use a string literal unless the JSON already dictates otherwise.
  3. Give each state only the fields it owns. error lives on the error member, data on the success member. Never both.
  4. Switch on the discriminant. Let narrowing hand you the right fields per branch.
  5. End every switch with assertNever. This is what converts "I forgot a case" into a red build instead of a 2 a.m. page.
  6. Turn on strict. Narrowing and never inference depend on it.

The payoff compounds. Every new state you add is a guided tour of every place that consumes it, and the tour guide is tsc. For the full rules on how narrowing works, the TypeScript Handbook's "Narrowing" and "Unions and Intersection Types" chapters are the canonical reference and worth a careful read — the assertNever trick is documented there as exhaustiveness checking.

My forever-spinner could not exist in a codebase that uses this pattern. The state that caused it is, quite literally, not a type you can write.

Further reading