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
loadinganderrorat 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
nevercheck 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
thismust 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 likestring.
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.
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
Add a state
In the first example add
| { status: "cancelled" }toRequestStateand read the errors. Which line complains, and why does it mentionnever? The line isconst unreachable: never = s;and the error isType '{ status: "cancelled"; }' is not assignable to type 'never'.: the one state you forgot is what is left ins. Then add thecase. - 2
Order does not matter
In the builder, swap the order of
urlandmethodin a third call and confirm it compiles. Then call.build()onRequestBuilder.create()directly and read thethiserror: it saysType '{}' is missing the following properties from type '{ url: string; method: "GET" | "POST"; }': url, method. - 3
Annotation vs satisfies
In the third example change
satisfies Record<string, ...>onroutesto a type annotationconst routes: Record<string, ...> = {...}. HoverdocsPathagain. It is no longer"/docs"(it becomes the wide pattern type, shown as `/${string}| undefinedbecause this course turns onnoUncheckedIndexedAccess), andkeyof typeof routesisstring`. That is what widening costs.
Code & diagrams
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 successThe 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 /usersThe 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" };
Break #2
Call build() too early
In the builder example, half.build() after only url().
Break #3
Forget a key in an exhaustive map
Remove archived from labels.
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
===,!==orswitch. A tag typedstringnarrows nothing. (Theinoperator 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. - ▸
satisfiesalso fixes the "wide key" problem:Record<string, X>annotation makeskeyofequalstring.satisfieskeeps literal keys, which is whykeyof typeof routescan feed other types such asRouteName. The values keep their exact type only when the target type asks for literals (as the `/${string}pattern does). Withsatisfies Record<string, string>the valuecolors.redis plainstring; writeas const satisfies Record<string, string>(in that order) to get"#f00"andreadonly` properties.
Remember this
- 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 allowsloading: truewith bothdataanderrorfilled in. Nothing in that object says which one is true, and every piece of code that reads it must guess. These objects have2 × 2 × 2combinations but only four meanings. The rest are illegal states. - 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(withdata) andfailure(witherror). Now{ status: "success", error: "oops" }is an error, and insidecase "success"the checker knowsdataexists, so you don't need?.or!. - 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 everyswitchthat must handle it, if you end eachswitchwith anevercheck (exhaustiveness, Topic 4.6). - 4
The typed builder uses a type parameter to remember what has been set so far. A
RequestBuilderwhosebuild()method declaresthis: RequestBuilder<{ url: string; method: ... }>can only be called after bothurl()andmethod()ran, in any order. Callingbuild()too early is a compile error, not a runtimeundefined. The builder's own methods return a new builder with a more precise type (Topic 6.7 showedthistypes). - 5
An exhaustive map uses
Record<Union, V>. IfStatushas 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. Aswitchcan 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
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/, yetroutes.docskeeps its exact type"/docs"andkeyof typeof routesis the exact set of names. A type annotation would check too, but it would replace the precise type with the annotation. Usesatisfiesfor config objects: validated and precise.
Explain it without notes
What does "make illegal states unrepresentable" mean? Give an example.
Why is a discriminated union better than optional fields for a request state?
What does the never check in a switch do?
When would you use satisfies rather than a type annotation?
Practice
Model a payment as a union: pending, paid (with paidAt: number) and refunded (with refundId: string). Write label(p) with an exhaustive switch.
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
ifchecks 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
switchwith anevercheck 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
satisfiesand explain the difference.