← Concepts & practices
Concept Data modeling and type design

Making illegal states unrepresentable

Shape data so the bad cases can’t be written.

You already know state shouldn’t be able to disagree with itself. Let’s follow the saved cards on a billing page, where every function works and the data still ends up with no default card, two default cards, and a count that’s wrong.

TypeScriptGo One wallet of saved cards, two implementations.

01 / The idea

A flag on every card is a fair start.

You’re building the billing page for a subscription product. The API returns saved cards, each with an isDefault flag, and the page keeps a cardCount for the “3 saved cards” heading. The flag draws the Default badge in one line, and the shape matches the JSON exactly.

Read the first saved cardsTypeScript · the version this lesson starts from
wallet.ts
// The first version: each saved card says whether it's the default, and the page keeps a count.
export type CardRow = Card & { isDefault: boolean };
export type SavedCards = { cards: CardRow[]; cardCount: number };

export function setDefaultRow(saved: SavedCards, id: string): SavedCards {
	return { ...saved, cards: saved.cards.map((card) => ({ ...card, isDefault: card.id === id })) };
}

export function removeRow(saved: SavedCards, id: string): SavedCards {
	const cards = saved.cards.filter((card) => card.id !== id);
	return { cards, cardCount: cards.length };
}

export function defaultRow(saved: SavedCards): CardRow | undefined {
	return saved.cards.find((card) => card.isDefault);
}

Go’s version embeds Card in a CardRow with an IsDefault field. Both languages meet again at Wallet in section 02.

Then renewals start failing with “no default card”: someone removed their default card, and removeRow did exactly what it says. A sync bug marks two cards as default, and the renewal charges whichever comes first. And a code path that forgets to update the count shows “3 saved cards” above two.

Every rule you keep by remembering is a rule some code path will forget. If the data can hold a combination that must never happen, eventually it will. Give each fact one place to live, work out anything that can be worked out, and let the shape hold only the states you mean. Yaron Minsky gave the idea its name in a 2011 Jane Street post: “Make illegal states unrepresentable.”

Section 05 builds a payment-methods list and a checkout picker whose state can’t disagree with itself, in React and Svelte.

02 / See the shape

One default field, and a count worked out from the cards.

The basic form is the shape: a Wallet keeps the default in its own field and the other cards in a list. In the wild adds the changes, which keep exactly one default. At the call site runs both versions through the same removals.

Both languages produce the same results.

The shape. A Wallet keeps the default in its own field and the other cards in a list, and works out the count.

TypeScriptReading
wallet.ts
// Exactly one default and never empty, because of where the cards are kept. The count is worked out.
export type Wallet = { readonly default: Card; readonly others: readonly Card[] };

export function cardCount(wallet: Wallet): number {
	return 1 + wallet.others.length;
}

export function allCards(wallet: Wallet): Card[] {
	return [wallet.default, ...wallet.others];
}
GoAlongside
wallet.go
// Wallet keeps exactly one default and never empty, because of where the cards are kept.
// Its fields are unexported, so no other package can fill them in. Any package can still
// write wallet.Wallet{}, so the methods treat that zero value as "no wallet".
type Wallet struct {
	defaultCard Card
	others      []Card
}

// ErrNoWallet is what the zero Wallet{} gets: it didn't come from WalletFrom.
var ErrNoWallet = errors.New("no wallet")

func (w Wallet) built() bool { return w.defaultCard.ID != "" }

func (w Wallet) Default() (Card, error) {
	if !w.built() {
		return Card{}, ErrNoWallet
	}
	return w.defaultCard, nil
}

func (w Wallet) CardCount() int {
	if !w.built() {
		return 0
	}
	return 1 + len(w.others)
}

func (w Wallet) AllCards() []Card {
	if !w.built() {
		return nil
	}
	return append([]Card{w.defaultCard}, w.others...)
}
Reading the TypeScriptA type is a shape, not a gate

Wallet can’t hold zero defaults or two. It can still be built by hand with the default repeated in others, so code that starts from API rows goes through walletFrom, which returns null when the default isn’t one of the cards.

removeCard returns a Removal union, so a caller has to look at ok before it can read the new wallet.

Reading the GoUnexported fields and the zero value

Wallet’s fields start with lower-case letters. The Go specification exports an identifier only when “the first character of the identifier’s name is a Unicode uppercase letter”, so another package can’t fill those fields in. The only way to get a wallet with cards is WalletFrom.

Any package can still write wallet.Wallet{}. That zero value has an empty default card, so the methods treat it as no wallet: Default and RemoveCard return ErrNoWallet, and CardCount is 0. The lesson’s test pins it. Removal errors are the sentinel values ErrLastCard and ErrChooseDefault.

03 / Follow the cards

Watch what each shape lets happen.

Five steps, each calling the lesson’s functions. The warnings under the first version are checks this page runs; the type itself says nothing. Before each step, guess which card is the default.

In Try it, remove cards and change the default in both versions at once.

Illegal states

What can this shape hold?

Two defaults, and a count that disagrees. First version · isDefault on each card: Visa 4242 (default), Mastercard 5555 (default), Amex 0005. Problems: 2 default cards; count says 4, list has 3. defaultRow(saved) gives 4242. The type accepts this value. Which card is charged depends on which default the lookup finds first.

01/ 05
Load saved cards with two defaults

The type allows two defaults.

Two cards say isDefault, the count says 4 for three cards, and defaultRow returns whichever comes first.

Reduced motion: choose a scene to see its completed state.

Read this scene

Two cards say isDefault, the count says 4 for three cards, and defaultRow returns whichever comes first.

Two defaults, and a count that disagrees. First version · isDefault on each card: Visa 4242 (default), Mastercard 5555 (default), Amex 0005. Problems: 2 default cards; count says 4, list has 3. defaultRow(saved) gives 4242. The type accepts this value. Which card is charged depends on which default the lookup finds first.

Watch restarts when you return. Step through keeps your selected step. Try it starts with three cards each time you open it.

What a tighter shape buys you

Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.

One default, always
A Wallet has a default field, so there’s never zero or two.
Counts that can’t drift
cardCount is worked out from the cards every time it’s asked.
Rules in one place
removeCard asks for a replacement, so no caller has to remember to pick one.
Bad rows stopped at the edge
walletFrom rejects a default that isn’t one of the cards.
No defensive checks downstream
A renewal reads wallet.default with no “what if there’s none” branch.

The review words are illegal state, invariant for “exactly one default”, single source of truth for keeping each fact once, and derived state for the count. Section 08 covers what they cost.

04 / Try a decision

A default that moves when another card is removed.

To avoid flags, someone kept the default as a position in the list. The code is in indexed.ts, and the lesson’s tests pin what happens.

Which card does the next renewal charge?

Instead of isDefault flags, someone stored the default as a position: { cards: [Visa 4242, Mastercard 5555, Amex 0005], defaultIndex: 1 }. Mastercard is the default. The customer removes Visa, and removeAt filters it out of cards.

05 / Give it a real job

A payment picker that can’t hold a stale card.

In the real product, the wallet is loaded on the billing page and used again at checkout, where a customer can pay with a different card for one order. Cards can be removed in another tab while checkout is open, and the order must never be charged to a card that isn’t saved.

Wallet

Cards and a default id

Loaded once, and passed down as it is.

This order

An id, or nothing

The only state checkout keeps.

Everything else

Worked out while rendering

The count, the default card, and the card for the order.

The example leaves out adding a card, expiry dates, and telling the server which card to charge.

Build UIs?Every piece of state you add is one more thing that can disagree with the rest, and one day a copied object or a stored count shows it.

Where it already is in your components

React’s guide to choosing state structure says it directly: “When the state is structured in a way that several pieces of state may contradict and ‘disagree’ with each other, you leave room for mistakes.” It also says that if you can calculate something from props or existing state during rendering, “you should not put that information into that component’s state.”

The textbook list keeps the cards and a defaultId, and counts the cards while rendering. In Svelte the count is a $derived, and the state is $state.raw, replaced on each change.

An id can still name a card that isn’t there, so the helper guards it the way section 02 does. paymentState refuses a default that isn’t one of the cards, and removeCard refuses the last card and won’t drop the default without a replacement. The list offers no Remove on the default.

When you have to own it

Now it’s checkout. React’s guide recommends holding “the selectedId in state” rather than a copy of the selected object. The picker keeps only the id picked for this order, and works out the card from the wallet on every render.

If that card is removed in another tab, the lookup finds nothing and falls back to the default, so the Pay button can’t name a card that isn’t saved.

payment-methods.ts
export type Card = { id: string; brand: string; last4: string };

// State keeps the cards once, and the default as an id. Everything else is worked out from them.
export type PaymentState = { readonly cards: readonly Card[]; readonly defaultId: string };

// The way in, like walletFrom in section 02: no state unless the default is one of the cards.
// No saved cards means no state, and the page shows an empty wallet instead.
export function paymentState(cards: readonly Card[], defaultId: string): PaymentState | null {
	return cards.some((card) => card.id === defaultId) ? { cards, defaultId } : null;
}

// States from paymentState, changed only by the functions below, always find their default.
export function defaultCard(state: PaymentState): Card {
	const card = state.cards.find((card) => card.id === state.defaultId);
	if (!card) throw new Error('The default is not one of the saved cards.');
	return card;
}

export function setDefault(state: PaymentState, id: string): PaymentState {
	return state.cards.some((card) => card.id === id) ? { ...state, defaultId: id } : state;
}

// The card for this order: the one picked if it still exists, otherwise the default.
export function cardForOrder(state: PaymentState, pickedId: string | null): Card {
	return state.cards.find((card) => card.id === pickedId) ?? defaultCard(state);
}

export type Removal =
	{ ok: true; state: PaymentState } | { ok: false; reason: 'last card' | 'choose a new default' };

// Like removeCard in section 02: the default goes only with a replacement, and the last card stays.
export function removeCard(state: PaymentState, id: string, replacementId?: string): Removal {
	const cards = state.cards.filter((card) => card.id !== id);
	if (cards.length === 0) return { ok: false, reason: 'last card' };
	if (id !== state.defaultId) return { ok: true, state: { cards, defaultId: state.defaultId } };
	if (!cards.some((card) => card.id === replacementId)) {
		return { ok: false, reason: 'choose a new default' };
	}
	return { ok: true, state: { cards, defaultId: replacementId as string } };
}

A payment-methods list whose state is the cards and a default id, with the count worked out while rendering.

ReactAlready in your code
PaymentMethods.tsx
import { useState } from 'react';
import { removeCard, setDefault, type PaymentState } from './payment-methods';

export function PaymentMethods({ initial }: { initial: PaymentState }) {
	// The cards once, and the default as an id: no isDefault flags to disagree, no count to update.
	const [state, setState] = useState(initial);
	const count = state.cards.length;

	function remove(id: string) {
		const removal = removeCard(state, id);
		if (removal.ok) setState(removal.state);
	}

	return (
		<section>
			<h2>
				{count} saved {count === 1 ? 'card' : 'cards'}
			</h2>
			<ul>
				{state.cards.map((card) => (
					<li key={card.id}>
						{card.brand} •••• {card.last4}
						{card.id === state.defaultId ? (
							// The default has no Remove: make another card the default first.
							<strong> Default</strong>
						) : (
							<>
								<button type="button" onClick={() => setState(setDefault(state, card.id))}>
									Make default
								</button>
								<button type="button" onClick={() => remove(card.id)}>
									Remove
								</button>
							</>
						)}
					</li>
				))}
			</ul>
		</section>
	);
}

06 / Recognize it elsewhere

Anywhere two pieces of data can say different things.

You’ve met all of these. For each one, find what can disagree and where the fact should live.

Familiar data that can disagree, and one place to keep it
Where you’ve seen itWhat can disagreeOne place to keep it
isLoading and isError flagsBoth true at onceOne status with named cases
A selected item copied into stateThe copy and the list after an editThe selected id
An order total stored beside its linesThe total and the linesWork the total out
Start and end dates as two fieldsAn end before the startOne range value that checks itself
A list kept with a selectedIndexThe index after an insert or removalAn id

Before adding a field, ask whether it can be worked out from the others. Before adding a flag, list the combinations and cross out the ones that mean nothing.

07 / Already in your toolbox

The idea already has a name and a guide.

Three places to look. For each one, find what can disagree and how the shape rules it out.

React · Choosing the State Structure

Avoiding contradictions, redundant state, and duplication, including the example that replaces a selected object with a selected id.

Read the guide ↗

Jane Street · Effective ML Revisited

Yaron Minsky’s 2011 post, where “Make illegal states unrepresentable” is one of the headings, shown with a before and after type.

Read the post ↗

Go · Exported identifiers

The rule that decides which fields another package can set, and so whether a type can only be built through its constructor.

Read the specification ↗
A useful counterexample: a read-only list from the APIWhen the flags are fine

An admin page that only displays each customer’s cards never removes or changes a default. Rendering the flags as they arrive is simpler, and there’s no code path to get them wrong.

08 / The parts to watch

A shape can rule out only what it can see.

These are the places it still goes wrong.

An index names a position, not a card

After a removal, defaultIndex can point at the wrong card, or past the end of the list at nothing. Keep an id, or the card itself.

The shape holds only inside your code

JSON can carry two defaults or a missing one. Convert it once, as walletFrom does, and handle the rows that don’t fit.

Some rules don’t fit a shape

“The default card isn’t expired” depends on today’s date. Keep it as a check, in one place.

Changes get stricter

removeCard needs a replacement, so the interface has to ask for one. That’s the rule surfacing, and it costs a screen.

Go’s zero value slips through

Unexported fields stop other packages from filling in a Wallet, but any package can write wallet.Wallet{}. Make every method check for that zero value and refuse it.

The stored shape and the model differ

The API sends cards and a defaultId; the code uses { default, others }. Every save needs the mapping back.

09 / Make the call

What would you have to change tomorrow?

Give both shapes a plausible change and follow the work it creates.

How a change affects flagged cards with a count and a Wallet
The changeFlags and a countWallet
Only display cards from the APIRender them as they come.A conversion for nothing.
Let people remove the defaultLeaves no default.Asks for a replacement.
A sync writes a second defaultAccepted.Can’t be expressed.
Add a card from a new flowRemember the count and the flag.Add it to others.
Refuse an expired defaultA check.Still a check.

Tighten the shape when code changes the data and a combination must never happen. Removing the default is the moment.

Keep the flat shape when the data is only displayed, or every combination is allowed.

The question I’d leave beside the code is: which combinations can this data hold that must never happen, and what writes them?

10 / Take the idea with you

Explain the failed renewal without saying “illegal state.”

“The data let a customer have saved cards with no default, and removing the default card made exactly that. We gave the default its own field, so removing it now means choosing another first.” In a review, the words are illegal state, invariant, and single source of truth.

Before moving on, jot down why the renewal failed, why the index charged Amex, and one stored value in your own code that could be worked out instead.

Connections to follow nextRelated lessons
  • Discriminated unions rule out illegal states with named cases, each carrying only its own data, and use exhaustiveness checking so the compiler finds every reader when a case is added.
  • Value objects rule them out with a value that checks its rules when it’s made.
  • Parse, don’t validate is how walletFrom turns API rows into a shape the rest of the code can trust.

Take the wallet into your editor. Add addCard(wallet, card, makeDefault), and write the test that proves a wallet still has exactly one default afterwards.

Back to Concepts & practices →