Command Palette

Search for a command to run...

Hectal
PHASE 10Advanced ~22 min· topic 5 of 6

Topic 10.5

Making Illegal States Unrepresentable

In one line

Instead of checking at runtime that a combination of values is allowed, shape the types so the wrong combination cannot even be written. Unions for states, typed builders, exhaustive Record maps and satisfies do most of the work.

Think of it like this

A traffic light. It can be red, yellow or green, one at a time. The designers did not build a light with three independent bulbs and then write a rule "never switch on red and green together"; they built a thing where that combination cannot happen. The best rule is one you cannot break. In types, we make the broken combinations impossible to write, so there is nothing left to check.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Illegal state
A combination of values that makes no sense, such as loading and error at the same time.
Discriminated union
A union whose members share a field with a different literal value in each (status), so checking it narrows the type.
State machine
A list of states plus the rules for moving from one to another.
Exhaustive
Every possible case is handled. A never check makes the compiler prove it.
Typed builder
An object you fill step by step whose type tracks which steps are done.
`this` parameter
A fake first parameter that declares what this must be when the method is called.
`satisfies`
Check a value against a type but keep the value's own, more exact type (TypeScript 4.9).
Widening
Replacing an exact type like "/docs" with a general one like string.

Step by step

01Spot the illegal states

LooseRequest has loading, data? and error?. The line { loading: true, data: ["x"], error: "boom" } compiles. What does it mean? Nobody knows.

Count the legal meanings (idle, loading, success, failure) and compare them with the combinations the type allows. The extra ones are the bugs waiting to happen.

02Model one state per variant

Write a union where each variant has a literal status and only its own fields. success carries data; failure carries error.

{ status: "success", error: "oops" } is now an error, with the message Object literal may only specify known properties.

state.tswhole filets
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "failure"; error: string };

03Make the switch exhaustive

End the switch with default: { const unreachable: never = s; return unreachable; }. If every case is handled, s has type never there and it compiles. If you add a fifth state and forget it, s is not never and the compiler stops you.

A transition function next(state, event) returning RequestState forces each branch to build a legal state.

04Remember steps in the type: the builder

create() starts with {}. url(u) returns a builder whose parts include url. build() demands this: RequestBuilder<{ url: string; method: "GET" | "POST" }>.

Calling build() after only url() gives a this error that names the missing method. The order of url and method doesn't matter.

05Maps and config objects

Record<Status, string> must list every status. Leave one out and you get Property 'archived' is missing.

For a config like routes, satisfies checks the rule ("every value starts with /") and keeps the precise type, so routes.docs is "/docs" and not string.

Try it yourself

  1. 1

    Add a state

    In the first example add | { status: "cancelled" } to RequestState and read the errors. Which line complains, and why does it mention never? The line is const unreachable: never = s; and the error is Type '{ status: "cancelled"; }' is not assignable to type 'never'.: the one state you forgot is what is left in s. Then add the case.

  2. 2

    Order does not matter

    In the builder, swap the order of url and method in a third call and confirm it compiles. Then call .build() on RequestBuilder.create() directly and read the this error: it says Type '{}' is missing the following properties from type '{ url: string; method: "GET" | "POST"; }': url, method.

  3. 3

    Annotation vs satisfies

    In the third example change satisfies Record<string, ...> on routes to a type annotation const routes: Record<string, ...> = {...}. Hover docsPath again. It is no longer "/docs" (it becomes the wide pattern type, shown as ` /${string} | undefined because this course turns on noUncheckedIndexedAccess), and keyof typeof routes is string`. That is what widening costs.

Code & diagrams

From a loose object to a union state machine

The loose object accepted a nonsense state; the union rejects it. Inside case success, data is known to exist. Add a fifth state to the union and watch the never line fail.

Console

(nothing was printed)

Expected console output

Loading...
2 items
true success
A typed builder: build() only after url() and method()

The first two calls compile in either order. The last build() is an error because method was never set; at runtime that gives the text undefined, the bug the checker predicted.

Console

(nothing was printed)

Expected console output

GET /users
POST /orders
undefined /users
Exhaustive Record maps and satisfies for config TypeScript 4.9+

The incomplete map is missing archived. satisfies rejects docs without a leading slash, and still keeps the exact type of the valid config.

Console

(nothing was printed)

Expected console output

Live /docs 2 /

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Mix fields from two states

In the first example, const bad: RequestState = { status: "success", error: "oops" };

terminal
$ Types panel
── what you'll see ──
index.ts(46,48): error TS2353: Object literal may only specify known properties, and 'error' does not exist in type '{ status: "success"; data: string[]; }'.

Break #2

Call build() too early

In the builder example, half.build() after only url().

terminal
$ Types panel
── what you'll see ──
index.ts(23,13): error TS2684: The 'this' context of type 'RequestBuilder<{ url: string; }>' is not assignable to method's 'this' of type 'RequestBuilder<{ url: string; method: "GET" | "POST"; }>'.

Break #3

Forget a key in an exhaustive map

Remove archived from labels.

terminal
$ Types panel
── what you'll see ──
index.ts(8,7): error TS2741: Property 'archived' is missing in type '{ draft: string; published: string; }' but required in type 'Record<Status, string>'.

Myth vs fact

Myth

Optional fields are the flexible way to model state.

Fact

Optional fields allow every combination, including the wrong ones. A union says exactly which fields go together.

Myth

satisfies and a type annotation are the same.

Fact

Both check. An annotation replaces the value's type with the annotation; satisfies keeps the value's own exact type.

Myth

The never check in default is optional decoration.

Fact

It is the part that makes the compiler prove every case is handled. It also protects you when the union grows later.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    A discriminated union narrows only when the tag is a literal type and compared with ===, !== or switch. A tag typed string narrows nothing. (The in operator narrows a union too, but by which property exists, not by a tag.) Mixed shapes narrow best with a shared tag; optional fields can't do it.

  • ▸

    Typed builders trade code size for safety: each method returns a new object, and complex versions with tuples of remaining steps make errors long. Often an options object (create({ url, method })) with required fields is simpler. Use a builder when the order or optional steps matter.

  • ▸

    satisfies also fixes the "wide key" problem: Record<string, X> annotation makes keyof equal string. satisfies keeps literal keys, which is why keyof typeof routes can feed other types such as RouteName. The values keep their exact type only when the target type asks for literals (as the ` /${string} pattern does). With satisfies Record<string, string> the value colors.red is plain string; write as const satisfies Record<string, string> (in that order) to get "#f00" and readonly` properties.

Remember this

  1. 1

    The problem this solves: Independent fields allow combinations that make no sense, such as loading and failed at once, and every reader has to guess which is true. A common beginner design is a set of independent fields: { loading: boolean; data?: string[]; error?: string }. It allows loading: true with both data and error filled in. Nothing in that object says which one is true, and every piece of code that reads it must guess. These objects have 2 × 2 × 2 combinations but only four meanings. The rest are illegal states.

  2. 2

    The fix is a discriminated union (Topic 4.5): one variant per real state, each with only the fields that make sense there. idle, loading, success (with data) and failure (with error). Now { status: "success", error: "oops" } is an error, and inside case "success" the checker knows data exists, so you don't need ?. or !.

  3. 3

    A state machine is a discriminated union plus a function that moves from one state to the next. Typing the function next(state, event) as returning the same union means every branch must produce a legal state. Add a new state and the compiler points to every switch that must handle it, if you end each switch with a never check (exhaustiveness, Topic 4.6).

  4. 4

    The typed builder uses a type parameter to remember what has been set so far. A RequestBuilder whose build() method declares this: RequestBuilder<{ url: string; method: ... }> can only be called after both url() and method() ran, in any order. Calling build() too early is a compile error, not a runtime undefined. The builder's own methods return a new builder with a more precise type (Topic 6.7 showed this types).

  5. 5

    An exhaustive map uses Record<Union, V>. If Status has three members, the object must have all three keys, so adding a fourth status makes every map fail to compile until it is filled in. A switch can do the same, but a map is data and is easier to read and reuse. The opposite is a plain { [key: string]: string }, which accepts any keys and never complains about a missing one.

  6. 6

    satisfies (TypeScript 4.9, Topic 5.6) checks an object against a type without widening it. const routes = { ... } satisfies ... with a rule that every value starts with / makes the compiler verify every route starts with /, yet routes.docs keeps its exact type "/docs" and keyof typeof routes is the exact set of names. A type annotation would check too, but it would replace the precise type with the annotation. Use satisfies for config objects: validated and precise.

Explain it without notes

01

What does "make illegal states unrepresentable" mean? Give an example.

02

Why is a discriminated union better than optional fields for a request state?

03

What does the never check in a switch do?

04

When would you use satisfies rather than a type annotation?

Practice

01

Model a payment as a union: pending, paid (with paidAt: number) and refunded (with refundId: string). Write label(p) with an exhaustive switch.

02

Write const colors = { red: "#f00", green: "#0f0" } satisfies Record<string, string>; and show that keyof typeof colors is exactly "red" | "green".

Trade-offs

  • ↔

    Precise state types need more code up front (a variant per state, a transition function) but remove whole groups of if checks and bugs. For a tiny flag, a boolean is fine.

  • ↔

    Typed builders catch missing steps at compile time, at the price of generics that are hard to read. Prefer an options object when every field is required.

Done when you can

  • I can turn a bag of optional fields into a discriminated union.

  • I can end a switch with a never check and explain why.

  • I can type a builder so build() is only callable when the required steps ran.

  • I can use Record<Union, V> to force a complete lookup table.

  • I can choose between an annotation and satisfies and explain the difference.