RizTech Academy logo
RizTech Academy
Writing TypeScript Worth ReadingLesson 3 of 530 min

Making illegal states unrepresentable

This is the most important idea in the whole course, and the one that separates people who use TypeScript from people who think in it: design your types so that illegal states cannot be represented. When invalid combinations are impossible to construct, an entire category of bug does not just get caught — it ceases to exist. This lesson gathers the techniques (unions, literals, branded types) into one principle.

The problem: types that allow nonsense

Here is a type that looks reasonable and is quietly a disaster:

interface RequestState {
  isLoading: boolean;
  data?: User;
  error?: string;
}

It models "a request that is loading, has data, or has an error." But count the combinations it allows: isLoading: true with data and error? A request that is loading and succeeded and failed at once — nonsense, but the type permits it. isLoading: false with neither data nor error? A finished request with no result and no error — also nonsense, also allowed. This type represents eight combinations when only three are valid, and every invalid one is a bug waiting to happen, guarded only by discipline and comments.

Every piece of code that touches RequestState must now defensively handle the impossible cases, and a bug where the state is inconsistent is invisible to the type system. The type is lying about what values are possible.

The fix: make illegal states unrepresentable

A discriminated union (from the unions module) models the exact set of valid states and nothing else:

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

Now count the combinations: exactly three, each carrying exactly the data that state has. A loading state has no data and no error — the type does not have those properties. A success state must have data and cannot have error. The invalid states are not caught; they cannot be written. You literally cannot construct { status: "loading", data: user } — loading has no data property. The type now tells the truth about what is possible, and the compiler enforces it everywhere:

function render(state: RequestState) {
  switch (state.status) {
    case "loading": return "Loading...";
    case "success": return state.data.name;    // data guaranteed here — no optional check
    case "error":   return state.error;          // error guaranteed here
  }
}

No defensive if (state.data) checks, no "what if it is loading but also has data" — the states are clean, and the exhaustiveness pattern (from the unions module) proves you handled all three. This is the payoff: a whole class of bug is designed out, not tested against.

Replace booleans and optionals that hide states

The general moves, which you apply constantly once you see them:

Multiple booleans that describe one thing → a union. isLoading + isSuccess + isError is three booleans allowing eight combinations for three real states. Replace with status: "loading" | "success" | "error" — one field, exactly three values.

A bare string where only some values are valid → a literal union. role: string allows any string (including typos); role: "admin" | "editor" | "viewer" allows exactly the three real roles. (The enums lesson's whole point.)

Optional properties that are actually conditional → a discriminated union. deliveredAt?: Date on an order is fine if it is genuinely optional; but if it is "present only when status is delivered", that correlation belongs in a union where the delivered variant has a required deliveredAt and the others do not have the property at all.

Each move takes a type that permits invalid combinations and replaces it with one that only permits valid ones. The question to ask of every type you design: can I construct a value of this type that should be impossible? If yes, tighten the type until I cannot.

Branded types — distinguishing values the compiler sees as identical

A subtler technique for a real bug: two values with the same underlying type that must not be mixed up. A UserId and an OrderId are both string, so TypeScript happily lets you pass one where the other is expected:

function getUser(id: string) { /* ... */ }
function getOrder(id: string) { /* ... */ }

const orderId = "order_123";
getUser(orderId);   // no error! both are strings — but this is a bug

Branded types make them distinct at the type level:

type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function getUser(id: UserId) { /* ... */ }

const orderId = "order_123" as OrderId;
getUser(orderId);   // error: OrderId is not assignable to UserId

The & { readonly __brand: "UserId" } adds a phantom property that exists only in the type (it is never real at runtime) — enough to make UserId and OrderId incompatible, so passing an order id where a user id is expected becomes a compile error. This is an advanced technique (and one of the few good uses of as, to create a branded value at a validated boundary), reserved for when mixing up same-typed values is a real risk — money amounts in different currencies, ids of different entities, validated versus unvalidated strings. Use it sparingly, where the bug it prevents is real.

The mindset

"Make illegal states unrepresentable" is not one technique; it is a way of designing. Whenever you write a type, ask: what invalid values does this allow, and can I remove them? A boolean where a union fits, a string where a literal union fits, optionals that should be a discriminated union, same-typed values that should be branded — each is an opportunity to push a class of bug from "possible, caught by tests" to "impossible, caught by the type." The best TypeScript developers spend real thought here, because a type that only permits valid values does more for correctness than any amount of runtime checking: the bug cannot be written in the first place. This is what it means to think in types, and it is the habit this whole course has been building toward.

Check your work

The problem with a loose type. It permits invalid combinations (a loading request with data and an error) — every invalid state is a bug guarded only by discipline.

The core principle. Design types so illegal states cannot be represented — invalid values become impossible to construct, not merely caught.

How a discriminated union fixes request state. Three variants each carrying exactly their data; you cannot write { status: "loading", data } because loading has no data.

Three general moves. Multiple booleans → a status union; a bare string → a literal union; conditional optionals → a discriminated union.

The question to ask of every type. Can I construct a value that should be impossible? If yes, tighten the type.

What branded types do. Make same-underlying-type values (a UserId and OrderId, both string) distinct at the type level, so mixing them is a compile error.

When to use branded types. Sparingly, where mixing up same-typed values is a real risk (ids, currencies, validated vs unvalidated).

Why this beats runtime checking. A type that only permits valid values makes the bug impossible to write, not just caught later.

Practice

  1. Write the loose RequestState (booleans + optionals) and construct three invalid values it allows.
  2. Rewrite it as a discriminated union and confirm each invalid value is now impossible to construct.
  3. Write a render over the union with no defensive checks and confirm data/error are guaranteed in the right branches.
  4. Replace three booleans describing one thing with a status union.
  5. Replace a role: string with a literal union and confirm a typo is rejected.
  6. Take an optional property that is actually conditional and move it into a discriminated union.
  7. Create branded UserId and OrderId types and confirm passing one where the other is expected errors.
  8. Review a type in code you know: list the invalid values it allows, and tighten it so it allows only valid ones.

Official documentation

Next: typed errors and the Result pattern.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship