advanced26 min

Modelling a Domain So Wrong States Cannot Exist

Bring the course together: use discriminated unions to make impossible states unrepresentable.

What you'll learn

  • Design a discriminated union for a state machine
  • Narrow on a discriminant property to access variant-specific fields
  • Use an exhaustiveness check so a new variant becomes a compile error

Prerequisites

Explanation

Here is a shape you have probably written:

interface RequestState {
  loading: boolean;
  data?: string;
  error?: string;
}

It permits states that make no sense: loading and holding an error; data and an error together; neither loading nor finished. Every consumer must then defend against combinations that should never occur — and eventually one forgets.

The discriminated union

Describe the states that genuinely exist, each with a shared literal discriminant:

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; message: string };

data now exists only on success, and message only on error. There is no way to construct a loading-with-an-error value: the illegal states are gone, not guarded against.

Narrowing on the discriminant

Checking status narrows to one variant, unlocking its fields:

function render(state: RequestState): string {
  switch (state.status) {
    case "idle":    return "Nothing yet";
    case "loading": return "Loading…";
    case "success": return state.data;      // only here does data exist
    case "error":   return state.message;   // only here does message exist
  }
}

Accessing state.data in the loading branch is a compile error, because that variant genuinely has no such field.

Exhaustiveness with never

The real payoff arrives months later, when someone adds a variant:

default: {
  const exhaustive: never = state;
  return exhaustive;
}

never is the type with no possible values. If every variant is handled, nothing reaches default and the assignment is fine. Add { status: "cancelled" } and that variant can reach default — it is not assignable to never, so the build fails and points at the switch you forgot.

That is the whole idea: push errors from runtime to compile time, and from "someone notices" to "the build stops."

Designing this way

When modelling, ask which combinations are actually possible, then write only those. Optional fields are frequently a hint that two or more distinct states have been flattened into one shape.

You now have the pieces: annotations and inference, arrays and objects, named shapes, unions and narrowing, optionality, functions, generics, derived types, literal types, and guards. Discriminated unions are where they combine into designs the compiler enforces for you.

Example

A four-state union, narrowing per branch, and an exhaustiveness check that would fail if a variant were added.

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; message: string };

function render(state: RequestState): string {
  switch (state.status) {
    case "idle":
      return "Nothing requested yet";
    case "loading":
      return "Loading…";
    case "success":
      return "Got: " + state.data;
    case "error":
      return "Failed: " + state.message;
    default: {
      const exhaustive: never = state;
      return exhaustive;
    }
  }
}

console.log(render({ status: "idle" }));
console.log(render({ status: "loading" }));
console.log(render({ status: "success", data: "42 rows" }));
console.log(render({ status: "error", message: "timeout" }));

Try it yourself

Add `| { status: "cancelled" }` to the union and Run. The never assignment turns the missing case into a compile error.

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Guided exercise

Guided exercise

Define `type Payment = { kind: "cash" } | { kind: "card"; last4: string }`. Write `describePayment(p: Payment): string` returning "Paid in cash" or "Card ending 4242".

Checks: describePayment is defined · Handles the cash variant · Handles the card variant

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Independent exercise

Independent exercise

Define `type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }`. Write `area(s: Shape): number` returning the correct area, rounded to 2 decimals with Math.round(x * 100) / 100.

Checks: area is defined · Computes a rectangle's area · Computes a circle's area

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Common mistakes

  • Flattening several states into one shape with optional fields, which permits combinations that should be impossible.
  • Using a boolean discriminant like `isError`. Two booleans already describe four states, most of which are nonsense.
  • Omitting the `never` exhaustiveness check, so adding a variant silently falls through instead of failing the build.

Knowledge check

Knowledge check

1. Why is `{ loading: boolean; data?: string; error?: string }` a weaker model?
2. What makes a union *discriminated*?
3. What happens when you add a variant but forget its case, with a `never` check present?

Takeaway

Model the states that can actually exist, and impossible states stop being a thing you defend against.

Summary

A discriminated union gives each variant a shared literal discriminant, so variant-specific fields exist only where they are valid. Narrowing on the discriminant unlocks those fields, and a `never` exhaustiveness check turns a forgotten variant into a build failure.

References

Your notes

Notes save automatically.

Finished this lesson?

Mark it complete to track your progress and schedule a future review.