A flat interface with optional fields is a promise the compiler cannot keep. Write { data?: T; error?: Error; loading?: boolean } and you have declared eight combinations, of which maybe three are real. The rest (data present while still loading, data and error at once) are noise your code defends against on every read. The type permits them, so somewhere a ! or an if exists only to handle states that should never have been spelled in the first place.

The fix is to stop describing fields and start describing cases. A discriminated union encodes which combinations are legal directly in the type, and the compiler enforces it for free.

A discriminant is a literal you can branch on

A discriminated union is a union of object types that share one field whose type is a distinct literal in each member. That field is the discriminant. Call it kind, status, or tag; the name is yours. Control-flow analysis reads it and narrows the union down to a single member inside each branch.

type Fetch<T> =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "success"; data: T }
  | { kind: "error"; message: string };

function render<T>(state: Fetch<T>): string {
  switch (state.kind) {
    case "idle":
      return "waiting";
    case "loading":
      return "spinner";
    case "success":
      return `got ${JSON.stringify(state.data)}`; // state: { kind: "success"; data: T }
    case "error":
      return state.message;                        // state: { kind: "error"; message: string }
  }
}

Inside case "success", state.data exists and state.message does not. The narrowing is structural, not a convention you maintain by hand. No path has both data and message, because no member of the union carries both. The invalid state is not guarded against at runtime. It cannot be constructed.

Compare that to the flat version. With data? and error? you can always reach for either, the compiler hands you T | undefined every time, and “success but error is also set” is a value the type system happily accepts. The union version deletes that possibility instead of documenting it.

Make the illegal unconstructable

The discipline generalizes past async state. Whenever two pieces of data only make sense together, or only make sense apart, put them in the same union member or in different ones. The board games in the archive lean on this hard.

Each Wordle tile resolves to exactly one of three judgments, and the judgment governs what else the tile carries:

type Tile =
  | { kind: "correct"; letter: string }   // right letter, right slot
  | { kind: "present"; letter: string }   // right letter, wrong slot
  | { kind: "absent"; letter: string };   // not in the word

type Score = (tiles: Tile[]) => number;

A tile cannot be both correct and absent. There is no boolean pair like { inWord: boolean; inPosition: boolean } where inPosition: true, inWord: false is representable and meaningless. The Wordle engine computes these states at the type level. Because the result is a union rather than a struct of flags, every downstream consumer narrows cleanly.

The Candy Crush board uses the same shape for cells. A cell holds a colored candy, or it is a hole left by a cleared match, or it is empty awaiting a refill:

type Cell =
  | { kind: "candy"; color: "red" | "green" | "blue" | "yellow" }
  | { kind: "empty" }
  | { kind: "hole" };

color lives only on candy. You never read a color off an empty cell, because the type for an empty cell has no color to read. The gravity and match-clearing logic walks the board switching on kind, and the compiler tracks which fields are in scope in each branch. State that the rules forbid was never expressible to begin with.

Exhaustiveness with never

Narrowing handles the cases you wrote. The harder problem is the case you forgot, and the case a teammate adds next quarter. A switch over a union compiles fine while missing a branch, so the gap surfaces at runtime, usually as a function that falls through and returns undefined.

The never type closes it. After a switch narrows away every member, the discriminant in the default branch has type never. Feed it to a helper that only accepts never and the branch type-checks. The moment a new variant appears, the discriminant is no longer never, and the call fails to compile.

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

function describe(cell: Cell): string {
  switch (cell.kind) {
    case "candy":
      return cell.color;
    case "empty":
      return "·";
    case "hole":
      return " ";
    default:
      return assertNever(cell); // cell: never, every member accounted for
  }
}

Now extend Cell with a fourth state, say a locked cell that blocks matches:

type Cell =
  | { kind: "candy"; color: "red" | "green" | "blue" | "yellow" }
  | { kind: "empty" }
  | { kind: "hole" }
  | { kind: "locked"; turnsLeft: number }; // new variant

describe is untouched, but it stops compiling:

// in the default branch of describe:
return assertNever(cell);
//                  ~~~~
// Argument of type '{ kind: "locked"; turnsLeft: number; }'
// is not assignable to parameter of type 'never'.

The default branch no longer narrows to never, because locked is unhandled and flows through. The error lands precisely where the missing logic belongs. You added no test, wrote no lint rule, and remembered nothing. Adding a state to the model produced a compile error at every site that consumes it, and the build stays red until each one handles the new case.

This is the property worth chasing. The flat-interface version of the same change is silent: a new flag is just another optional field, and nothing forces existing readers to acknowledge it. The union plus assertNever converts “we added a state” from a runtime surprise into a list of compiler errors that doubles as a to-do list.

One caveat on the discriminant

Exhaustiveness narrowing only works when the discriminant is a single literal field shared across every member. Widen it, give one member kind: string, or drop the field on a member, and the union stops being discriminated. The default branch no longer collapses to never, and the check goes quiet without telling you. Keep the discriminant a bare literal on every member and the analysis stays exact.

Why this is the load-bearing pattern

Discriminated unions and the never check together give you a closed model. “Closed” means the set of legal states is finite, named, and enforced, and the compiler forces every function over that model to be total. Unrepresentable bad states need no defensive code. Unhandled good states are caught before they ship.

That is also why the pattern scales into the type-level work in this archive. A union of states with a literal discriminant is one transition function away from a type-level state machine, where the legal moves between states live in the types too, and an illegal transition is a type error rather than a bug.