Strategy

Rider assignment began as one rule: pick the nearest. When it rained, nearest riders turned up late and soaked, so a mode parameter was added: 'nearest' or 'topRated'. At lunch peak, the nearest rider was already carrying three orders, so 'leastBusy' joined it. The function now had three branches, 80 lines, and a comment asking people not to add a fourth.

Then the growth team wanted to try a fourth on 5% of orders, as an experiment, without a deploy each time they tuned it.

Your answer

You have three ways to pick a rider (nearest, top rated, least busy), you need to switch between them while the app is running, and more are coming. How would you structure the code?

The idea

The picture to keep
Car, bike or walk in Maps

You type a destination. Then you tap car, bike or walk, and the route changes. The map, the search box and the ETA label are all the same. Only the piece that works out the route was swapped, and it was swapped while the app was running.

In one line: Put each algorithm in its own object behind one interface, and let the caller choose which one to plug in.

Three roles. The strategy is the interface for the swappable algorithm. A concrete strategy is one algorithm. The context is the object that holds a strategy and uses it, without knowing which one it has.

The classic form

type Rider = {
  name: string
  distanceKm: number
  rating: number
  activeOrders: number
}
 
interface AssignmentStrategy {
  pick(riders: Rider[]): Rider
}
 
class Nearest implements AssignmentStrategy {
  pick(riders: Rider[]): Rider {
    return riders.reduce((best, rider) =>
      rider.distanceKm < best.distanceKm ? rider : best,
    )
  }
}
 
class TopRated implements AssignmentStrategy {
  pick(riders: Rider[]): Rider {
    return riders.reduce((best, rider) =>
      rider.rating > best.rating ? rider : best,
    )
  }
}
 
class LeastBusy implements AssignmentStrategy {
  pick(riders: Rider[]): Rider {
    return riders.reduce((best, rider) =>
      rider.activeOrders < best.activeOrders ? rider : best,
    )
  }
}
 
class Dispatcher {
  constructor(private strategy: AssignmentStrategy) {}
 
  setStrategy(strategy: AssignmentStrategy): void {
    this.strategy = strategy
  }
 
  assign(riders: Rider[]): string {
    return `Assigned to ${this.strategy.pick(riders).name}`
  }
}
 
const riders: Rider[] = [
  { name: 'Asha', distanceKm: 1.2, rating: 4.4, activeOrders: 2 },
  { name: 'Ravi', distanceKm: 2.5, rating: 4.9, activeOrders: 1 },
  { name: 'Meena', distanceKm: 3.1, rating: 4.7, activeOrders: 0 },
]
 
const dispatcher = new Dispatcher(new Nearest())
console.log(dispatcher.assign(riders)) // Assigned to Asha
 
dispatcher.setStrategy(new TopRated())
console.log(dispatcher.assign(riders)) // Assigned to Ravi
 
dispatcher.setStrategy(new LeastBusy())
console.log(dispatcher.assign(riders)) // Assigned to Meena

Dispatcher is the context. Its assign method has no if and no mode. It calls this.strategy.pick and uses the answer. The same dispatcher object produced three different assignments because the strategy inside it was replaced between calls.

The growth team’s experiment is now a fourth class and one line that decides when to plug it in. Dispatcher, Nearest, TopRated and LeastBusy are not opened. That is the open closed principle, and Strategy is the most common way of getting it.

Figure 1. The dispatcher holds one strategy at a time and calls pick() on it. Swapping the strategy changes the behaviour without changing the dispatcher.

The TypeScript form: a strategy is a function

Each strategy above is a class with one method and no fields. In TypeScript that is a function, and you can say so in the type.

type Rider = {
  name: string
  distanceKm: number
  rating: number
  activeOrders: number
}
type AssignmentStrategy = (riders: Rider[]) => Rider
 
const nearest: AssignmentStrategy = (riders) =>
  riders.reduce((best, rider) =>
    rider.distanceKm < best.distanceKm ? rider : best,
  )
 
const topRated: AssignmentStrategy = (riders) =>
  riders.reduce((best, rider) =>
    rider.rating > best.rating ? rider : best,
  )
 
type StrategyName = 'nearest' | 'topRated'
 
const strategies: Record<StrategyName, AssignmentStrategy> = {
  nearest,
  topRated,
}
 
function assign(riders: Rider[], pick: AssignmentStrategy): string {
  return `Assigned to ${pick(riders).name}`
}
 
const riders: Rider[] = [
  { name: 'Asha', distanceKm: 1.2, rating: 4.4, activeOrders: 2 },
  { name: 'Ravi', distanceKm: 2.5, rating: 4.9, activeOrders: 1 },
]
 
const isRaining = true
const pick = isRaining ? strategies.topRated : strategies.nearest
 
console.log(assign(riders, pick)) // Assigned to Ravi

Same pattern, a third of the code. The strategies table lets you choose one by name, from a config value or an experiment flag, which is what the growth team asked for.

You have been using this pattern for as long as you have written JavaScript:

const prices = [120, 40, 60]
 
const ascending = [...prices].sort((a, b) => a - b)
const descending = [...prices].sort((a, b) => b - a)
 
console.log(ascending) // [ 40, 60, 120 ]
console.log(descending) // [ 120, 60, 40 ]

sort is the context. The comparison function you pass in is the strategy. The sorting algorithm never changes. You plug in the one decision that varies.

ChoiceWhat you gainWhat you payPick it when
Strategy as a functionMinimal code. Any function with the right signature works, including an inline arrow.No place for configuration or dependencies, other than closing over them.The algorithm is one operation with no state. This is most cases.
Strategy as a classCan hold configuration, take dependencies through its constructor, and offer several related methods.More code, and one class per algorithm.The algorithm needs its own data, like a surge multiplier, or a service to call, or more than one method.

Mistakes people make

The strategy that peeks at the context

If TopRated.pick starts reading fields off the Dispatcher, or the dispatcher checks if (this.strategy instanceof LeastBusy), the two are tied together again and the swap is no longer clean. Pass the strategy what it needs as arguments. The context should treat every strategy the same way.

Choosing the strategy inside the context

If Dispatcher contains this.strategy = isRaining ? new TopRated() : new Nearest(), the if ladder you removed has come back in a different method. The choice belongs outside: in a factory, in config, or in the code that creates the dispatcher.

Strategy versus State

The class diagrams are identical: a context holding an object behind an interface. The difference is who swaps it and why. With Strategy, the caller picks the algorithm, and strategies do not know about each other. With State, the object changes its own state as things happen to it, and each state knows which one comes next. Strategy is “how should I do this?”. State is “what am I right now?”.

TypeScript you just picked up
interface AssignmentStrategy { pick(riders: Rider[]): Rider }
The contract every algorithm fulfils.
constructor(private strategy: AssignmentStrategy) {}
private without readonly, because setStrategy replaces it later.
type AssignmentStrategy = (riders: Rider[]) => Rider
A function type. The whole strategy interface in one line.
riders.reduce((best, rider) => ...)
reduce with no starting value begins with the first element. It throws on an empty array.
Record<StrategyName, AssignmentStrategy>
A table of strategies with a fixed, checked set of names.
{ nearest, topRated }
Shorthand for { nearest: nearest, topRated: topRated }.
[...prices].sort((a, b) => a - b)
Copy first, because sort changes the array it is called on.

Checkpoint

Checkpoint

1. In the Strategy pattern, what does the context know about the algorithm it is using?

2. Each of your strategies is a class with one method and no fields. What is the idiomatic TypeScript simplification?

3. What is the key difference between Strategy and State, given that their structure looks the same?

Say this in 60 seconds

Strategy is for when there are several ways to do one job and I need to switch between them at runtime. I define an interface for the algorithm, put each version in its own implementation, and have a context object hold one and call it without knowing which it is. Adding a new algorithm is a new class or function, and nothing existing changes, so it is the usual way to satisfy open closed. In TypeScript a strategy with one method and no state is a function type, and passing a comparator to sort is the pattern in everyday use. The choice of which strategy to use stays outside the context, in config or a factory. It differs from State in who does the swapping: with Strategy the caller chooses, with State the object changes itself.

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