From switch statements to strategies
Think of a navigation app. You pick a destination, then choose driving, walking or public transport. The app does the same thing in all three cases (“find me a route”), but how it finds the route is completely different.
The app doesn’t contain one giant function with if driving … else if walking …. Each way of routing is a separate piece, and the app just asks the one you picked: “give me a route”. That separate piece is a strategy.
In code, a strategy is:
- an interface (a protocol in Swift) that describes the job, like “calculate shipping cost”,
- several implementations of it, one per way of doing the job,
- a context that holds one implementation and uses it, without knowing which one it is.
Strategy is a design patternDesign patternA named, reusable solution to a problem that keeps coming up in software design.Open in glossary → that encapsulates an interchangeable algorithm behind an interface, so the client chooses (or is given) the algorithm at runtime and the context depends only on the abstraction. In practice it’s the open/closed principleOpen/closed principleCode should be open for extension but closed for modification: add new behavior without editing what already works.Open in glossary → made concrete: adding a variant means adding a type, not editing a switch.
In languages with first-class functions it’s often just a function value, and the interesting questions are elsewhere: enum + switch or protocol + types? (the expression problem), how strategies are selected (config, server flags, DI), and how they stay stateless so they can be shared. All of that is in the Detailed version.
Here’s the shipping code before and after:
Before: Every new shipping method edits every switch
enum ShippingMethod { case standard, express, pickup }
func shippingCost(_ order: Order, _ method: ShippingMethod) -> Double {
switch method {
case .standard: return order.total >= 50 ? 0 : 4.99
case .express: return 9.99 + Double(order.itemCount) * 0.5
case .pickup: return 0
}
}
func maxDeliveryDays(_ method: ShippingMethod) -> Int {
switch method {
case .standard: return 5
case .express: return 1
case .pickup: return 2
}
}
// …and another switch for the label, and one in analyticsenum class ShippingMethod { STANDARD, EXPRESS, PICKUP }
fun shippingCost(order: Order, method: ShippingMethod): Double = when (method) {
ShippingMethod.STANDARD -> if (order.total >= 50) 0.0 else 4.99
ShippingMethod.EXPRESS -> 9.99 + order.itemCount * 0.5
ShippingMethod.PICKUP -> 0.0
}
fun maxDeliveryDays(method: ShippingMethod): Int = when (method) {
ShippingMethod.STANDARD -> 5
ShippingMethod.EXPRESS -> 1
ShippingMethod.PICKUP -> 2
}
// …and another when for the label, and one in analyticsenum ShippingMethod { STANDARD, EXPRESS, PICKUP }
static double shippingCost(Order order, ShippingMethod method) {
return switch (method) {
case STANDARD -> order.total() >= 50 ? 0 : 4.99;
case EXPRESS -> 9.99 + order.itemCount() * 0.5;
case PICKUP -> 0;
};
}
static int maxDeliveryDays(ShippingMethod method) {
return switch (method) {
case STANDARD -> 5;
case EXPRESS -> 1;
case PICKUP -> 2;
};
}
// …and another switch for the label, and one in analyticstype ShippingMethod = "standard" | "express" | "pickup";
function shippingCost(order: Order, method: ShippingMethod): number {
switch (method) {
case "standard": return order.total >= 50 ? 0 : 4.99;
case "express": return 9.99 + order.itemCount * 0.5;
case "pickup": return 0;
}
}
function maxDeliveryDays(method: ShippingMethod): number {
switch (method) {
case "standard": return 5;
case "express": return 1;
case "pickup": return 2;
}
}
// …and another switch for the label, and one in analyticsAfter: One type per shipping method
protocol ShippingOption {
var name: String { get }
var maxDeliveryDays: Int { get }
func cost(for order: Order) -> Double
}
struct StandardShipping: ShippingOption {
let name = "Standard"
let maxDeliveryDays = 5
func cost(for order: Order) -> Double { order.total >= 50 ? 0 : 4.99 }
}
struct ExpressShipping: ShippingOption {
let name = "Express"
let maxDeliveryDays = 1
func cost(for order: Order) -> Double { 9.99 + Double(order.itemCount) * 0.5 }
}
struct Checkout {
let order: Order
var shipping: any ShippingOption // the strategy, swappable at runtime
var total: Double { order.total + shipping.cost(for: order) }
}interface ShippingOption {
val name: String
val maxDeliveryDays: Int
fun cost(order: Order): Double
}
object StandardShipping : ShippingOption {
override val name = "Standard"
override val maxDeliveryDays = 5
override fun cost(order: Order) = if (order.total >= 50) 0.0 else 4.99
}
object ExpressShipping : ShippingOption {
override val name = "Express"
override val maxDeliveryDays = 1
override fun cost(order: Order) = 9.99 + order.itemCount * 0.5
}
class Checkout(private val order: Order, var shipping: ShippingOption) {
val total: Double get() = order.total + shipping.cost(order)
}interface ShippingOption {
String name();
int maxDeliveryDays();
double cost(Order order);
}
final class StandardShipping implements ShippingOption {
public String name() { return "Standard"; }
public int maxDeliveryDays() { return 5; }
public double cost(Order order) { return order.total() >= 50 ? 0 : 4.99; }
}
final class ExpressShipping implements ShippingOption {
public String name() { return "Express"; }
public int maxDeliveryDays() { return 1; }
public double cost(Order order) { return 9.99 + order.itemCount() * 0.5; }
}
final class Checkout {
private final Order order;
private ShippingOption shipping; // the strategy, swappable at runtime
Checkout(Order order, ShippingOption shipping) { this.order = order; this.shipping = shipping; }
void setShipping(ShippingOption shipping) { this.shipping = shipping; }
double total() { return order.total() + shipping.cost(order); }
}interface ShippingOption {
readonly name: string;
readonly maxDeliveryDays: number;
cost(order: Order): number;
}
const standardShipping: ShippingOption = {
name: "Standard",
maxDeliveryDays: 5,
cost: (order) => (order.total >= 50 ? 0 : 4.99),
};
const expressShipping: ShippingOption = {
name: "Express",
maxDeliveryDays: 1,
cost: (order) => 9.99 + order.itemCount * 0.5,
};
class Checkout {
constructor(private readonly order: Order, public shipping: ShippingOption) {} // the strategy, swappable
get total(): number {
return this.order.total + this.shipping.cost(this.order);
}
}Look at what changed:
- Everything about express shipping now lives in one place. To add store pickup, you write one new type and touch nothing else.
Checkoutonly knows aboutShippingOption. When the user taps a different option, you replacecheckout.shippingand the total updates.- Each strategy is tiny, so it’s easy to test: create an order, call
cost, check the number.
The Kotlin version uses object for each strategy, and TypeScript a plain object literal: they hold no state, so one shared instance is enough. Keep strategies stateless (or immutable) and they’re trivially thread-safe and reusable; push per-request data in through parameters, as cost(order) does.
Practice
Enum + switch or protocol + types?
This is the expression problem, and neither side wins everywhere:
Enum with exhaustive switch / when | Interface with one type per variant | |
|---|---|---|
| Add a variant | Edit every switch (the compiler lists them) | Add one type, touch nothing else |
| Add an operation | Add one function | Edit every type |
| Variants defined outside the module | Impossible | Easy (plugins, server-driven, other teams) |
| Exhaustiveness checking | Yes | No |
Swift enums with associated values, Kotlin sealed hierarchies and TypeScript discriminated unions sit in between: closed sets with exhaustive checks, and behavior that can live on the cases. For a fixed, small set that many operations switch over, they’re often the better choice. Strategy pays off when the set is open or the behavior is chosen at runtime.
Strategy in the platforms you use
- Swift:
JSONDecoder.keyDecodingStrategyanddateDecodingStrategy(.convertFromSnakeCase,.iso8601, or.custom { … }). - Android:
TimeInterpolatordecides how an animation progresses;LinearInterpolator,OvershootInterpolatoror your own. - Java / Spring:
Comparator, Spring Security’sPasswordEncoder(BCrypt, Argon2, delegating), and retry back-off policies. - TypeScript / Node: the compare function of
sort, thereviverofJSON.parse, and Passport.js, whose login methods are literally called strategies (passport.use(new GoogleStrategy(…))).
Strategy vs State
Both look identical in a class diagram. The difference is who switches: with Strategy, the client picks the algorithm and the context just uses it. With State, the states themselves move the context to the next state (an order goes from Paid to Shipped). If your “strategies” start calling context.setStrategy(...), you’ve built a state machine.
| Swift | Kotlin | Java | TypeScript | |
|---|---|---|---|---|
| Interface + types | protocol + struct | interface + object / class | interface + class | interface + object literals or classes |
| Closed set with behavior | enum with methods | sealed interface / enum class with methods | enum with abstract method, sealed | union + Record<Kind, Strategy> |
| Stored in context | any ShippingOption | ShippingOption | ShippingOption | ShippingOption |
- It’s the expression problem: an enum with an exhaustive
switchmakes new operations cheap and new variants a checked edit; an interface with one type per variant makes new variants cheap. Choose Strategy when the set is open or chosen at runtime. - Strategy vs State: with Strategy the client picks; when the objects pick the next implementation themselves, it’s State.
Check this part
What does
Checkoutneed to know about shipping after the refactor?Marketing adds store pickup. What changes in the strategy version?
With an enum and exhaustive
switches instead of strategies, what does adding a variant cost?Your "strategies" start calling
context.setStrategy(next)themselves. What pattern is that?