An order is a draft, then paid, then shipped. It can be cancelled while it is a draft, and refunded once it is paid — but a shipped order cannot be cancelled, and a draft cannot be refunded.
What people write first
A field, and a check at the top of every method.
class Order { status: 'draft' | 'paid' | 'shipped' | 'cancelled' = 'draft';
pay() { if (this.status !== 'draft') throw new Error('cannot pay'); this.status = 'paid'; }
ship() { if (this.status !== 'paid') throw new Error('cannot ship'); this.status = 'shipped'; }
cancel() { if (this.status === 'shipped') throw new Error('cannot cancel'); if (this.status === 'cancelled') throw new Error('already cancelled'); this.status = 'cancelled'; }}Three problems, and they get worse together.
The rules are scattered — to know what a paid order can do you must read
every method. Adding a state means auditing all of them. And it is silently easy
to get incomplete: cancel above forgets nothing today, but add a refunded
state and it will.
The pattern
Make each state an object, and give it only the methods that are legal in it.
interface OrderState { readonly name: string; pay?(order: Order): void; ship?(order: Order): void; cancel?(order: Order): void;}
const Draft: OrderState = { name: 'draft', pay: (order) => order.transitionTo(Paid), cancel: (order) => order.transitionTo(Cancelled), // no ship — a draft cannot ship, and there is nothing to check};
const Paid: OrderState = { name: 'paid', ship: (order) => order.transitionTo(Shipped), cancel: (order) => order.transitionTo(Cancelled), // this one is a refund};
const Shipped: OrderState = {name: 'shipped'}; // terminal: no methods at allPress the buttons — the greyed ones are not disabled, they are absent:
A draft order can pay() or cancel(). The greyed buttons are not disabled by a check; they are absent from this state.
Reading the machine
The other benefit is that the transition diagram is now in the code, one state
per object. To answer “what can a paid order do?” you read Paid. To add a
Refunded state you add one object, and the compiler flags the transitions that
should point at it.
Compare that to grepping for status === across a class.
When a table beats objects
If the states have no behaviour beyond moving to the next state, objects are overkill. A table says the same thing in less space:
const TRANSITIONS = { draft: {pay: 'paid', cancel: 'cancelled'}, paid: {ship: 'shipped', cancel: 'cancelled'}, shipped: {}, cancelled: {},} as const;
function send(order: Order, event: string) { const next = TRANSITIONS[order.status][event]; if (!next) throw new IllegalTransition(order.status, event); order.status = next;}This is the same machine, and it has a property the object version does not: it is data, so it can be validated, visualised, or loaded from a config file. Reach for objects when the states carry real behaviour, and a table when they mostly carry structure.
State or Strategy?
Same structure: an object holding varying behaviour, swapped at runtime. The catalogue separates them by who swaps:
- Strategy is chosen by the caller and does not change itself. Strategies do not know about each other.
- State changes itself in response to events. States reference each other by design — that is the transition graph.
If your objects name each other, it is State. If they are interchangeable and mutually ignorant, it is Strategy.