Strategy vs Function References and Closures
What This Concept Is
A Strategy encapsulates interchangeable behavior behind a contract that may have one or several operations. For a single operation in a language with first-class functions, a plain function reference or closure often supplies that boundary compactly.
The question is not "which is better". It is: when does the OO form earn its weight, and when should you just pass a function?
Decision axis:
- Function reference / closure -- convenient for a callable operation, including captured configuration and injected dependencies with clear ownership.
- Strategy object -- useful for multiple related operations, named invariants or an explicit resource lifecycle.
Why It Matters Here
An interface with one implementation can support a valuable dependency boundary, or can add ceremony without benefit. Explain the testing, ownership or variation pressure before choosing its representation.
If flags and captured state obscure the contract, compare a named configuration value, smaller functions and an object with explicit invariants. None is an automatic consequence of parameter count.
Concrete Example
Sorting in TypeScript. The comparator is the varying step; no object is required.
type User = { name: string; lastLogin: Date; priority: number };
type Comparator<T> = (a: T, b: T) => number;
const byName: Comparator<User> =
(a, b) => a.name.localeCompare(b.name);
const byRecentLogin: Comparator<User> =
(a, b) => b.lastLogin.getTime() - a.lastLogin.getTime();
// Closure that captures configuration.
function byPriorityThen(tieBreaker: Comparator<User>): Comparator<User> {
return (a, b) => (b.priority - a.priority) || tieBreaker(a, b);
}
function sortUsers(users: User[], cmp: Comparator<User>): User[] {
return [...users].sort(cmp);
}
sortUsers(users, byName);
sortUsers(users, byPriorityThen(byRecentLogin));
Now compare against a Strategy object version:
interface UserComparator {
compare(a: User, b: User): number;
}
class ByName implements UserComparator {
compare(a: User, b: User) { return a.name.localeCompare(b.name); }
}
A function type is compact here; an interface does not inherently require separate files. Configuration and named lifecycle responsibilities can justify a class, but a configured closure can also carry a locale or logger.
Common Confusion / Misconception
- "Always use classes in OO." Modern Java, C#, Python, Kotlin, Scala, and Swift all support function types or SAM conversion. Closures can represent callable strategies and may capture state; that state still needs an ownership contract.
- "Always use functions -- classes are ceremony." If the strategy needs named state, invariants, or multiple methods, classes are clearer. Choose according to visible invariants, ownership and testing needs, not a captured-variable threshold.
- Function reference is not the same as inline lambda forever. Give a function a name when it communicates intent or supports useful reuse; call-site count is a heuristic. Anonymous strategies hide intent.
How To Use It
Apply this checklist when you think you want Strategy:
- One method? If yes, prefer a function type. If no, use an object.
- State or lifecycle? If the variant owns resources or needs
close(), use an object. - Configuration lifetime? A closure or object may capture reusable configuration; per-request values can be explicit parameters in either representation.
- Testing? Both can be testable when their inputs, state and effects are visible. Evaluate the actual test seam.
- Team conventions? In a codebase where strategies are objects everywhere, do not introduce one-off functions without discussion.
Hybrid is fine. A compare closure and a Paymentprocessor object can coexist.
Check Yourself
- What three properties push you toward a Strategy object over a function?
- When does a one-method interface with one implementation earn its cost?
- When is "just a function" still worth naming and extracting?
- Where does language idiom (Java SAM, Python callables, TS types) affect the decision?
Mini Drill or Application
Take a validation component that currently accepts a list of validator objects, each with a single validate(value): boolean method.
- Rewrite it to accept a list of
(value) -> booleanfunctions. - Identify which validators actually have internal state (regex cache, DB connection, counter) and keep them as objects.
- Document why each choice was made in a one-line comment above the validator.
Stop when the code reads cleanly at both call sites.
Read This Only If Stuck
- GoF: Strategy Applicability (book index)
- GoF: Strategy Known Uses (book index)
- Refactoring: Replace Command with Function (book index)
- Head First: Speaking of Design Patterns (book index)
- Head First: Exploring Strategy (book index)
Contract and transfer
A comparator has laws beyond (a,b) -> number: consistent self-comparison,
sign reversal when arguments swap, and transitive ordering. A comparator that
reads a changing clock may violate these laws during one sort. Fix the observation
time before sorting if the policy requires a stable ranking. Decide how invalid
dates and ties behave; a numeric-looking interface does not settle that policy.
The TypeScript sample assumes a supplied users array with valid dates. The
shallow copy protects array order but still shares User objects. Mutating a user's
name through the result mutates that same user seen by the caller. A closure
capturing mutable configuration observes later changes unless it copies/freezes
the relevant value.
Transfer: record the results of a captured discount after its configuration changes. Compare live configuration, captured immutable configuration, and an explicit per-call parameter. Document which behavior the client needs before choosing a function or object. P02 asks for this distinction; L01 checks the public contract.