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
// 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.
// 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];
} // 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.
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.
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
Wallethas adefaultfield, so there’s never zero or two. - Counts that can’t drift
cardCountis worked out from the cards every time it’s asked.- Rules in one place
removeCardasks for a replacement, so no caller has to remember to pick one.- Bad rows stopped at the edge
walletFromrejects a default that isn’t one of the cards.- No defensive checks downstream
- A renewal reads
wallet.defaultwith 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.
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.
Cards and a default id
Loaded once, and passed down as it is.
An id, or nothing
The only state checkout keeps.
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.
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.
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.
| Where you’ve seen it | What can disagree | One place to keep it |
|---|---|---|
isLoading and isError flags | Both true at once | One status with named cases |
| A selected item copied into state | The copy and the list after an edit | The selected id |
An order total stored beside its lines | The total and the lines | Work the total out |
| Start and end dates as two fields | An end before the start | One range value that checks itself |
A list kept with a selectedIndex | The index after an insert or removal | An 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.
| The change | Flags and a count | Wallet |
|---|---|---|
| Only display cards from the API | Render them as they come. | A conversion for nothing. |
| Let people remove the default | Leaves no default. | Asks for a replacement. |
| A sync writes a second default | Accepted. | Can’t be expressed. |
| Add a card from a new flow | Remember the count and the flag. | Add it to others. |
| Refuse an expired default | A 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
walletFromturns API rows into a shape the rest of the code can trust.