Strategy vs State
Published
Draw Strategy and draw State and you get the same picture: a main object (the context) holds a reference to an interface, and hands work to whichever object is plugged in. If you learned them from class diagrams, there is no visible difference. The difference is in who does the swapping.
Take two pictures from the LLD course. Strategy is choosing car, bike or walk in Maps: you pick the mode, and the "car" option has no idea the "walk" option exists. The route will never decide on its own to become a walking route. State is a traffic signal: nobody outside picks the next colour. Green knows that yellow comes after it, and yellow knows red comes next. The signal changes itself.
An order goes Placed, Paid, Out for delivery, Delivered, and can be Cancelled from some of those. Separately, checkout lets the customer pick Standard, Express or Free delivery. Which of these is Strategy and which is State? What is the one difference you used to decide?
Watch who changes it#
Try both halves. On the left you are the caller and you swap the rule. On the right you can only press actions, and the current state decides what the order becomes.
Strategy: the caller picks how#
A strategy is one way of doing a job, chosen from outside. The options are interchangeable, independent, and usually picked once per job: per request, per customer, per experiment.
interface ShippingRule {
fee(cartTotal: number): number
}
class Standard implements ShippingRule {
fee() {
return 40
}
}
class FreeAbove499 implements ShippingRule {
fee(cartTotal: number) {
return cartTotal >= 499 ? 0 : 40
}
}
class Checkout {
constructor(private shipping: ShippingRule) {}
setShipping(rule: ShippingRule) {
this.shipping = rule
}
total(cartTotal: number) {
return cartTotal + this.shipping.fee(cartTotal)
}
}
const checkout = new Checkout(new Standard())
console.log(checkout.total(600)) // 640
checkout.setShipping(new FreeAbove499()) // the caller swaps it
console.log(checkout.total(600)) // 600Standard and FreeAbove499 never mention each other. Checkout never decides which one to
use. Someone outside, the customer's choice or a config flag, makes that decision.
State: the object changes itself#
A state is the behaviour of an object at one point in its life. Each state answers every
action, and the answer often includes "and now you are in this other state". The caller only
says what happened (pay, cancel), never which state comes next.
interface OrderState {
readonly name: string
pay(order: Order): void
cancel(order: Order): void
}
class Order {
state: OrderState = new Placed()
pay() {
this.state.pay(this)
}
cancel() {
this.state.cancel(this)
}
}
class Placed implements OrderState {
readonly name = 'Placed'
pay(order: Order) {
order.state = new Paid()
}
cancel(order: Order) {
order.state = new Cancelled()
}
}
class Paid implements OrderState {
readonly name = 'Paid'
pay() {
console.log('already paid')
}
cancel(order: Order) {
order.state = new Cancelled()
}
}
class Cancelled implements OrderState {
readonly name = 'Cancelled'
pay() {
console.log('order was cancelled')
}
cancel() {
console.log('already cancelled')
}
}
const order = new Order()
order.pay()
console.log(order.state.name) // Paid
order.cancel()
console.log(order.state.name) // Cancelled
order.pay()
// > order was cancelledPlaced knows that paying leads to Paid. Nobody calls order.setState(new Paid()) from
outside. That one detail, the states choosing their successors, is what makes this State and
not Strategy.
The differences, side by side#
| Strategy | State | |
|---|---|---|
| Who swaps it | The caller, from outside | The current state, from inside |
| Do the options know each other | No, they are independent | Yes, each knows which states can follow |
| What varies | Usually one algorithm (a fee, a sort, a route) | The whole behaviour of several methods at once |
| How often it changes | Once per job, or per config | Many times in one object's life |
| The smell it replaces | if (mode === 'express') in one function | if (status === 'paid') repeated in every method |
| Lighter TypeScript form | A function | A union type and a transition table |
The "smell" row is the practical one. If one function has a growing switch on a mode, that is
a Strategy waiting to happen. If five methods each have the same if (status === ...) ladder,
and someone forgot to update one of them (the State chapter
opens with exactly that bug), that is a State machine waiting to happen.
The lighter forms in TypeScript#
Both patterns are smaller in TypeScript than in a textbook. A strategy is often just a function:
type ShippingRule = (cartTotal: number) => number
const standard: ShippingRule = () => 40
const express: ShippingRule = () => 90
const freeAbove499: ShippingRule = (t) => (t >= 499 ? 0 : 40)
function checkout(total: number, shipping: ShippingRule) {
return total + shipping(total)
}
console.log(checkout(350, standard)) // 390
console.log(checkout(350, express)) // 440
console.log(checkout(600, freeAbove499)) // 600And a state machine is often a union type plus a table of allowed moves. Every rule is in one place, and the compiler checks that you spelled every status correctly:
type Status = 'placed' | 'paid' | 'delivered' | 'cancelled'
type Action = 'pay' | 'deliver' | 'cancel'
const next: Record<Status, Partial<Record<Action, Status>>> = {
placed: { pay: 'paid', cancel: 'cancelled' },
paid: { deliver: 'delivered', cancel: 'cancelled' },
delivered: {},
cancelled: {},
}
function act(status: Status, action: Action): Status {
const to = next[status][action]
if (!to) throw new Error(`cannot ${action} when ${status}`)
return to
}
console.log(act('placed', 'pay')) // paid
console.log(act('paid', 'cancel')) // cancelledUse the class form when each state has real behaviour of its own (different validation, different side effects). Use the table when states differ only in which moves are allowed, which is most order, ticket and booking flows.
Watch for strategies that start swapping themselves. A retry policy that is "fast" for three
attempts and then calls client.setPolicy(new SlowPolicy()) is no longer a strategy, because
the option now picks its successor. That is fine, but call it a state machine and draw its
transitions, or the next person will not find the rule that changed the policy.
If an order can only be open or closed, a boolean and one if is clearer than three
classes. Reach for State when the same status checks appear in several methods, or when a
missed combination has already caused a bug.
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Strategy | New algorithms are new code, chosen at runtime, testable on their own. | The caller has to know which strategies exist and pick one. | Several interchangeable ways to do one job: pricing, shipping fees, rider assignment. |
| State (classes) | Each state's behaviour is in one class, and invalid moves are handled in one place. | One class per state, and the transitions are spread across them. | States with genuinely different behaviour, not only different allowed moves. |
| State (union and table) | Every transition visible in one object, checked by the compiler. | Per state behaviour has to live in switch statements elsewhere. | Most order, payment, ticket and booking lifecycles. |
type Status = 'placed' | 'paid'Record<Status, X>Partial<Record<Action, Status>>readonly name = 'Placed'type ShippingRule = (t: number) => numberIn the interview#
Checkpoint#
1. What is the most reliable way to tell Strategy from State in code?
2. Five methods on Booking each start with the same if (status === ...) ladder. Which pattern is this asking for?
3. A strategy object starts calling context.setStrategy(new OtherStrategy()). What has happened?
- Same shape, different hands on the switch.
- Strategy: the caller picks, the options do not know each other. Maps: car, bike, walk.
- State: the object moves itself, each state knows what can follow. The traffic signal.
- Smell: one growing switch on a mode is Strategy; the same status if in many methods is State.
- In TypeScript: a strategy is often a function; a state machine is often a union plus a
Record.
Strategy and State have the same structure, a context holding an interface and delegating to it, so I tell them apart by who swaps the object. In Strategy the caller chooses from outside, the options are independent and interchangeable, and it usually varies one algorithm, like shipping fees or rider assignment. In State the object changes itself: each state handles every action and decides which state comes next, so the states know each other, and it varies the behaviour of several methods over the object's life, like an order going from placed to paid to delivered. The smell for Strategy is one growing switch on a mode; for State it is the same status checks repeated in many methods. In TypeScript a strategy is often just a function, and most lifecycles fit a union type with a Record of allowed transitions. If a strategy starts choosing its successor, it has become a state machine and should be drawn as one.