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.

Your answer

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

The picture to keep
The traffic signal

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.

In one line: The object behaves differently depending on its current state. Each state is its own class that knows what is allowed now and what comes next.

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 started

The 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 delivered

Order 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.

Figure 1. Each box is a class. Each arrow is a transition that the state it starts from decides to make. There is no arrow out of delivered or cancelled, and so no code for one.

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.

ChoiceWhat you gainWhat you payPick it when
Transition tableThe 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 classesEach 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

Transitions decided from outside

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 pattern for two states

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.

Where this shows up in LLD rounds

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”.

TypeScript you just picked up
readonly name: string
A property in an interface. Implementations must have it, and nobody may reassign it.
readonly name = 'placed'
The class supplies the value. Its type is narrowed to that exact string.
private state: OrderState = new Placed()
A field typed by the interface with a concrete starting value.
this.state.next(this)
Passing the current object to another, so the state can call back and change it.
cancel(): string
Implementing cancel(order: Order) with fewer parameters is allowed.
Record<Status, readonly Status[]>
One required row per status, each a read only list of statuses.
transitions[from].includes(to)
Index into the table with a typed key, then search the array.

Checkpoint

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?

Say this in 60 seconds

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.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.