State
A customer cancelled an order while the rider was standing at their door. The app gave a full refund. The customer also took the food.
cancel() checked if (status !== 'delivered'). That was correct when there were two
statuses. By now there were five, and “out for delivery” had been added to assignRider,
to markDelivered and to the tracking screen, but nobody had gone back to cancel. Five
methods, five statuses, 25 combinations, and the rules for them were spread across if
statements in every method.
An order moves through placed, cooking, out for delivery and delivered. cancel() and next() should behave differently at each stage. Where would you put the rule for what cancel() does while the order is cooking, so that adding a new stage cannot leave it forgotten?
The idea
Same junction, same cars, same drivers. What you are allowed to do depends on which light is on. And each light knows its successor: green hands over to amber, amber to red. Nobody at the junction holds a rulebook with every case in it. The light that is on right now is the rule.
Before: every method asks “what state am I in?”
type Status = 'placed' | 'cooking' | 'delivered' | 'cancelled'
class Order {
status: Status = 'placed'
next(): void {
if (this.status === 'placed') this.status = 'cooking'
else if (this.status === 'cooking') this.status = 'delivered'
}
cancel(): string {
if (this.status === 'placed') {
this.status = 'cancelled'
return 'Cancelled, full refund'
}
if (this.status === 'cooking') {
return 'Too late, the kitchen has started'
}
if (this.status === 'delivered') return 'Already delivered'
return 'Already cancelled'
}
}
const order = new Order()
order.next()
console.log(order.cancel()) // Too late, the kitchen has startedThe rules for “cooking” are split between next and cancel, and will be split again
across every method added later. To learn what a cooking order can do, you read the whole
class and collect the middle branch of each ladder. Adding a status means visiting every
method, and the compiler will not tell you if you miss one.
After: one class per state
interface OrderState {
readonly name: string
next(order: Order): void
cancel(order: Order): string
}
class Order {
private state: OrderState = new Placed()
get status(): string {
return this.state.name
}
moveTo(state: OrderState): void {
this.state = state
}
next(): void {
this.state.next(this)
}
cancel(): string {
return this.state.cancel(this)
}
}
class Placed implements OrderState {
readonly name = 'placed'
next(order: Order): void {
order.moveTo(new Cooking())
}
cancel(order: Order): string {
order.moveTo(new Cancelled())
return 'Cancelled, full refund'
}
}
class Cooking implements OrderState {
readonly name = 'cooking'
next(order: Order): void {
order.moveTo(new Delivered())
}
cancel(): string {
return 'Too late, the kitchen has started'
}
}
class Delivered implements OrderState {
readonly name = 'delivered'
next(): void {}
cancel(): string {
return 'Already delivered'
}
}
class Cancelled implements OrderState {
readonly name = 'cancelled'
next(): void {}
cancel(): string {
return 'Already cancelled'
}
}
const order = new Order()
console.log(order.status) // placed
order.next()
console.log(order.status) // cooking
console.log(order.cancel()) // Too late, the kitchen has started
order.next()
console.log(order.status) // delivered
console.log(order.cancel()) // Already deliveredOrder has no if left in it. order.cancel() passes the call to whichever state
object it currently holds, and that object answers for its own stage. The two
order.cancel() calls at the bottom are the same code and give different results,
because the state inside the order was replaced in between.
Everything a cooking order can do is in the Cooking class, eleven lines, readable in
one go. And a state decides its own successor: Placed.next moves the order to
Cooking. Nothing outside tells it to.
Now add “out for delivery”. You write one class. implements OrderState forces it to
answer both next and cancel. The bug at the top of this page cannot be written,
because a new state without a cancel rule does not compile.
One piece of TypeScript to notice: Cooking.cancel() takes no parameter although the
interface says cancel(order: Order). That is allowed. A method may ignore arguments it
does not need, and it still satisfies the interface.
The TypeScript form: a union and a table
If the states differ only in which moves are allowed, you do not need classes. The figure above is a table, and you can write it as one.
type Status = 'placed' | 'cooking' | 'delivered' | 'cancelled'
const transitions: Record<Status, readonly Status[]> = {
placed: ['cooking', 'cancelled'],
cooking: ['delivered'],
delivered: [],
cancelled: [],
}
function move(from: Status, to: Status): Status {
if (!transitions[from].includes(to)) {
throw new Error(`Cannot go from ${from} to ${to}`)
}
return to
}
let status: Status = 'placed'
status = move(status, 'cooking')
console.log(status) // cooking
try {
status = move(status, 'cancelled')
} catch (error) {
if (error instanceof Error) console.log(error.message)
// > Cannot go from cooking to cancelled
}The whole state machine is six lines that a product manager could review.
Record<Status, ...> gives the same safety as the interface did: add a status to the
union and the table will not compile until it has a row.
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Transition table | The entire machine is visible in one place, and it is plain data you can log, test or draw. | It only says which moves are legal. Behaviour that differs per state still needs code somewhere. | States differ mainly in what they can move to. Start here. |
| State classes | Each state carries its own behaviour for every operation, and can hold its own data, like a cooking start time. | One class per state, and the overall flow is spread across them. | Several operations each behave differently per state, which is when the if ladders were hurting. |
Getting it wrong
If a controller does order.moveTo(new Delivered()) directly, the states no longer
guard anything, and a placed order can jump to delivered. Keep moveTo for the state
classes to call, and give outside code only the events: next(), cancel(). In a
larger codebase you would enforce that by keeping Order and its states in one module
and exporting only Order.
A boolean isActive with one if does not need four classes. The pattern pays off
when you have three or more states and several operations that each depend on the
state. Below that, the if is shorter and clearer.
Vending machine, ATM, elevator, traffic light, order lifecycle, document approval. These questions are State pattern questions with a costume on. The opening move is always the same: list the states, list the events, and draw which event moves which state where. Do that on the board before writing a class, and say “each state will be its own class, so an unhandled combination is a compile error”.
readonly name: stringreadonly name = 'placed'private state: OrderState = new Placed()this.state.next(this)cancel(): stringRecord<Status, readonly Status[]>transitions[from].includes(to)Checkpoint
1. After the refactor, order.cancel() is one line. How does it give a different result for a placed order and a cooking order?
2. You add a new state class and forget to write its cancel() method. What happens?
3. Your statuses differ only in which other statuses they may move to. Nothing else changes per status. What is the simplest design?
The State pattern is for an object whose behaviour depends on which stage it is in, like an order that is placed, cooking or delivered. Instead of every method checking the status with an if ladder, I make one class per state behind a common interface. The object holds its current state and forwards calls to it, and each state decides what the call does and which state comes next. All the rules for one stage are then in one class, and a new state that forgets a rule does not compile. In TypeScript, when states only differ in their allowed transitions, a union type and a transition table is simpler and I start with that. It looks like Strategy, but with Strategy the caller picks the behaviour, and with State the object moves between behaviours itself.
