Every codebase eventually grows a function that decides how to do something based on a string. It starts as two branches, and a year later it is two hundred lines that nobody wants to touch. The Strategy pattern is the standard cure: pull each branch into its own object, and let the caller pick one at runtime.
It is one of the few Gang of Four patterns that stays genuinely useful in TypeScript, partly because the language makes it cheap. You will see both the textbook version and the version people actually write day to day.
The problem
Here is a shipping calculator for a checkout. Nothing about it is unreasonable when it is first written.
type Order = {
weightKg: number
subtotal: number
destination: 'domestic' | 'international'
}
function calculateShipping(order: Order, carrier: string): number {
if (carrier === 'standard') {
return order.weightKg * 1.5 + 5
}
if (carrier === 'express') {
return order.weightKg * 3 + 12
}
if (carrier === 'pickup') {
return 0
}
throw new Error(`Unknown carrier: ${carrier}`)
}Now the requests arrive. International orders need a customs fee. Express should be free above a certain subtotal. A fourth carrier appears, then a fifth, each with its own promotional rules. The branches stop being one-liners and start nesting, and the function becomes the single place where five unrelated pricing policies live.
The concrete symptoms are worth naming, because they are what the pattern actually fixes:
- Every change touches the same function. Two people adding two carriers get a merge conflict for no good reason.
- You cannot test a policy in isolation. To check the express rule you call the function that also contains the other four.
- The compiler stops helping.
carrieris astring, so a typo is a runtime error rather than a red squiggle. - Adding a carrier means editing code that already works, which is exactly the code you least want to risk breaking.
The shape of the pattern
Strategy has three parts, and it is worth learning the vocabulary because it is what the rest of the literature uses:
- The strategy interface defines the operation, here "given an order, return a shipping cost".
- The concrete strategies each implement that operation one way.
- The context holds a strategy and delegates to it, without knowing or caring which one it got.
The point is that the context depends on the interface, never on the concrete implementations. Adding a sixth carrier means adding a file, not editing five existing ones.
Refactoring to Strategy
Start with the interface. In TypeScript this is just a type.
export type Order = {
weightKg: number
subtotal: number
destination: 'domestic' | 'international'
}
export interface ShippingStrategy {
readonly name: string
cost(order: Order): number
}Each carrier becomes its own implementation, and each one is now free to be as complicated as its own rules require without leaking that complexity anywhere else.
import type { Order, ShippingStrategy } from './strategy'
export class StandardShipping implements ShippingStrategy {
readonly name = 'standard'
cost(order: Order): number {
const base = order.weightKg * 1.5 + 5
return order.destination === 'international' ? base + 20 : base
}
}
export class ExpressShipping implements ShippingStrategy {
// Free express above this subtotal, a promotion that only concerns this carrier
private static readonly FREE_THRESHOLD = 150
readonly name = 'express'
cost(order: Order): number {
if (order.subtotal >= ExpressShipping.FREE_THRESHOLD) return 0
return order.weightKg * 3 + 12
}
}
export class StorePickup implements ShippingStrategy {
readonly name = 'pickup'
cost(): number {
return 0
}
}The context receives a strategy and delegates. It has no idea how many carriers exist.
import type { Order, ShippingStrategy } from './strategy'
export class Checkout {
constructor(private strategy: ShippingStrategy) {}
/** Lets the customer change carrier without rebuilding the checkout. */
use(strategy: ShippingStrategy) {
this.strategy = strategy
}
total(order: Order): number {
return order.subtotal + this.strategy.cost(order)
}
}And the call site reads like the business rule it implements:
const checkout = new Checkout(new StandardShipping())
checkout.total(order) // 5 + weight * 1.5, plus customs if international
checkout.use(new ExpressShipping())
checkout.total(order) // recalculated with the express rulesChoosing a strategy from data
The if chain has not disappeared yet, it has only moved to wherever a carrier name arrives from a form or an API. Replace it with a lookup table, and let TypeScript derive the valid names from the table itself.
import { ExpressShipping, StandardShipping, StorePickup } from './strategies'
import type { ShippingStrategy } from './strategy'
const strategies = {
standard: new StandardShipping(),
express: new ExpressShipping(),
pickup: new StorePickup(),
} satisfies Record<string, ShippingStrategy>
export type CarrierName = keyof typeof strategies
export function carrier(name: CarrierName): ShippingStrategy {
return strategies[name]
}CarrierName is now 'standard' | 'express' | 'pickup', computed from the registry rather than maintained by hand. Passing a name that does not exist is a compile error, and adding a carrier extends the union automatically.
In TypeScript, a function is often enough
The class-per-strategy version above is the classic formulation, and it earns its keep when a strategy has dependencies, configuration or several related methods. When a strategy is a single operation, which is most of the time, a function is a strategy and the ceremony buys you nothing.
export type ShippingStrategy = (order: Order) => number
export const standard: ShippingStrategy = (order) => {
const base = order.weightKg * 1.5 + 5
return order.destination === 'international' ? base + 20 : base
}
export const express: ShippingStrategy = (order) => (order.subtotal >= 150 ? 0 : order.weightKg * 3 + 12)
export const pickup: ShippingStrategy = () => 0
export function total(order: Order, shipping: ShippingStrategy) {
return order.subtotal + shipping(order)
}This is the same pattern. The interface is a function type, the concrete strategies are functions, and the context takes one as a parameter. Reach for classes when a strategy needs state or a constructor, and for functions otherwise.
Why the tests get better
This is usually the argument that convinces a team. Each policy is now testable on its own, with no checkout, no cart and no knowledge of the other carriers.
import { describe, expect, it } from 'vitest'
import { ExpressShipping } from './strategies'
describe('ExpressShipping', () => {
const order = { weightKg: 2, subtotal: 40, destination: 'domestic' as const }
it('charges by weight below the free shipping threshold', () => {
expect(new ExpressShipping().cost(order)).toBe(18)
})
it('is free once the subtotal reaches the threshold', () => {
expect(new ExpressShipping().cost({ ...order, subtotal: 150 })).toBe(0)
})
})The context gets its own test, and it does not need a real carrier at all, since any function or object matching the interface will do:
it('adds the shipping cost to the subtotal', () => {
const flatFee = { name: 'test', cost: () => 10 }
expect(new Checkout(flatFee).total(order)).toBe(50)
})That last test is the pattern paying off: the context is verified against a stub written in one line, because it only ever depended on the interface.
When it is overkill
Strategy is easy to over-apply, and an abstraction with one implementation is worse than the if it replaced. Some honest guidance:
- Two stable branches that will never grow. An
ifis clearer, and clarity wins. Refactor when the third arrives, not before. - The branches are not interchangeable. Strategy assumes every implementation satisfies the same contract with the same meaning. If one of them needs an extra argument or returns something different, you have a different problem.
- The variation is data, not behaviour. If your carriers only differ by a rate and a flat fee, a table of numbers beats a table of classes.
// No pattern required: the variation really is just two numbers
const rates = {
standard: { perKg: 1.5, flat: 5 },
express: { perKg: 3, flat: 12 },
pickup: { perKg: 0, flat: 0 },
}The moment a carrier needs a rule the table cannot express, that is the signal to move to strategies.
You are already using it
Once the shape is familiar it turns up everywhere, which is a good sign that it is a natural fit for the problem rather than an invention:
array.sort(comparator)takes a comparison strategy. The sorting algorithm is the context.- Passport.js calls its authentication mechanisms strategies, quite literally.
- A
Loggerthat writes to the console in development and to a hosted service in production is a strategy chosen by environment. - Payment providers, storage backends, cache drivers and retry policies are all the same structure with a different noun.
Takeaways
Strategy is a small pattern with a clear trigger. When a function branches on a value to decide how to perform an operation, and that set of branches keeps growing, give each branch its own home behind a shared interface.
Keep it honest by adding an implementation only when it exists in the product, by deriving the valid names from the registry so the compiler catches typos, and by preferring plain functions until a strategy genuinely needs to be an object.