Skip to article
ALGORITHMICSPatterns
Patterns6 min read

State

Behaviour that follows the mode — and how the illegal transitions stop being reachable.


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 all

Press 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:

If your objects name each other, it is State. If they are interchangeable and mutually ignorant, it is Strategy.