Definition
The Strategy pattern defines a family of interchangeable algorithms behind one interface, so the algorithm can vary independently of the clients that use it.
After Gamma et al., Design Patterns (1994), Strategy
01Start with one inline rule
One search, two useful answers to “first.”
A documentation search returns records with a relevance score and an age. For one view, copying the response and sorting by score is a sensible, easy-to-follow start.
// One view, one rule: sort a copy by score, right where the view needs it.
const ranked = [...results].sort((a, b) => b.score - a.score); Then readers ask for a “Recent” option, and a command palette wants the same validation and tie behavior. Repeated branches can work, but every caller must remember to copy the list, validate the same fields, and settle ties the same way.
Strategy gives the changing policy a common contract and lets the surrounding workflow call it. Here the policy compares two records; the ranker still prepares the input and returns the ordered copy.
02Pass the rule in
Pass the rule where the decision used to sit.
Both policies take the same pair of records. Relevance compares scores; Recent compares age. The ranker receives one policy, copies the list, and resolves a tie with the record ID.
This is the whole program the lab runs: the policies, the ranker, and one response ranked both ways.
type SearchResult = { id: string; score: number; ageDays: number };
// A policy compares two results. Negative: a first. Zero: tied. Positive: b first.
type RankingPolicy = (a: SearchResult, b: SearchResult) => number;
function byRelevance(a: SearchResult, b: SearchResult): number {
return b.score - a.score;
}
function byRecent(a: SearchResult, b: SearchResult): number {
return a.ageDays - b.ageDays;
}
// The shared workflow: copy, ask the policy, and settle its ties by ID.
function rankResults(items: SearchResult[], policy: RankingPolicy): SearchResult[] {
const ranked = [...items];
function compare(a: SearchResult, b: SearchResult): number {
const order = policy(a, b);
const tied = order === 0;
if (!tied) return order;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
ranked.sort(compare);
return ranked;
}
const results: SearchResult[] = [
{ id: 'cache', score: 90, ageDays: 30 },
{ id: 'api', score: 90, ageDays: 7 },
{ id: 'release', score: 65, ageDays: 1 },
];
const ids = (items: SearchResult[]) => items.map((item) => item.id).join(', ');
const relevance = rankResults(results, byRelevance);
const recent = rankResults(results, byRecent);
console.log(`relevance: ${ids(relevance)}`);
console.log(`recent: ${ids(recent)}`);
console.log(`as returned: ${ids(results)}`); | Role | Owner | What it does |
|---|---|---|
| Caller · supplies | Search view | Owns the selected mode, the response snapshot, rendering, and visible failure feedback. |
| Workflow · delegates | Result ranker | Validates, copies, invokes the policy, and settles ties by ID. |
| Policy · decides | Ordering function | Compares two records under one rule. It does not fetch or render them. |
What a production ranker addsChoosing by name, and validation
The same ranker, with two additions. The caller chooses the policy by name, so an unknown name fails before any record is looked at. And the ranker validates every record before a policy sees it.
export type SearchResult = Readonly<{ id: string; score: number; ageDays: number }>;
// A policy compares two results. Negative: a first. Zero: tied. Positive: b first.
export type RankingPolicy = (a: SearchResult, b: SearchResult) => number;
export const byRelevance: RankingPolicy = (a, b) => b.score - a.score;
export const byRecent: RankingPolicy = (a, b) => a.ageDays - b.ageDays;
// The caller's choice, by name, before any record is checked.
export function choosePolicy(name: string): RankingPolicy {
switch (name) {
case 'relevance':
return byRelevance;
case 'recent':
return byRecent;
default:
throw new Error('unknown ranking policy');
}
}
export function validateResults(items: readonly SearchResult[]): void {
const ids = new Set<string>();
for (const item of items) {
if (!/^[a-z0-9-]+$/.test(item.id))
throw new Error('id must use lowercase ASCII letters, digits, or hyphens');
if (ids.has(item.id)) throw new Error('duplicate result id');
ids.add(item.id);
if (!Number.isInteger(item.score) || item.score < 0 || item.score > 100)
throw new Error('score must be an integer from 0 to 100');
if (!Number.isInteger(item.ageDays) || item.ageDays < 0 || item.ageDays > 365)
throw new Error('age must be an integer from 0 to 365 days');
}
}
// The shared workflow: validate, copy, ask the policy, and settle its ties by ID.
export function rankResults(items: readonly SearchResult[], policy: RankingPolicy): SearchResult[] {
validateResults(items);
return [...items].sort((a, b) => {
const order = policy(a, b);
if (order !== 0) return order;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
} An ID is unique, nonempty, and uses lowercase ASCII letters, digits, or hyphens. Score is a whole number from 0 through 100; age is a whole number from 0 through 365 days. Relevance puts higher scores first; Recent puts lower ages first. Both break primary ties by ascending ID.
Every valid record appears once and the input order is left alone. Empty input succeeds. An unknown policy fails before record validation; records are then checked in input order for ID format, duplicate ID, score, and age. Arbitrary JSON needs parsing before it is treated as a result record.
03Predict who settles a tie
Two results tie. Who decides?
Experiment
Rank one response two ways
TODO
Visual to come
The response’s three cards (results) stay in a top row in their returned order. For each ranking, the ranker’s working copy (ranked) reorders card by card as the sort runs; each comparison highlights the pair a and b, the policy being asked (byRelevance or byRecent) and the number it returns, and when tied is true the ranker’s own ID comparison lights up instead of the policy. The finished copies stay beside the response as relevance and recent.
Reading the TypeScriptFunction types and shallow copies
RankingPolicy is a function type. A negative result puts the first argument
earlier; a positive result puts it later; zero means a tie. The spread creates a new array
before sort changes its order. The record objects are still shared.
Production wiringThe caller chooses once; the workflow owns the rest
The search page and command palette can each receive a response snapshot, select a policy, call the same ranker, and render its returned order. A changed preference affects the comparison function; it does not change how the list is validated or copied. Search scoring and retrieval remain upstream responsibilities.
If the product adds “Lowest score,” the caller exposes that choice and supplies another comparator. The ranker stays the same.
04Meet it in your sort calls
Every sort call hands over a policy.
Array.prototype.sort takes the policy as a comparator, then rearranges the array
it was called on and returns that same array. In a component, that array is usually someone
else’s state: sorting it during render reorders the parent’s list behind its back, and every
other view of that list sees the new order.
import { useMemo } from 'react';
import { ResultList } from './ResultList';
import { rankResults, type RankingPolicy, type SearchResult } from './ranking';
type Props = { results: SearchResult[]; policy: RankingPolicy };
// Before: sort() rearranges the array it is given, here the parent's state.
function ResultsBefore({ results, policy }: Props) {
const ranked = results.sort(policy);
return <ResultList items={ranked} />;
}
// After: hand the list to the ranker, which copies it and settles ties.
// Pass a named policy (byRelevance), not an inline arrow: a new function on
// every render changes useMemo's dependencies and re-ranks every time.
function Results({ results, policy }: Props) {
const ranked = useMemo(() => rankResults(results, policy), [results, policy]);
return <ResultList items={ranked} />;
} Hand the list to the ranker from section 02: it copies once for every caller and keeps the
tie rule, which a bare toSorted (ES2023; your TypeScript lib must
include it) would drop. Choose the policy by name so its identity is stable between renders. Read the documentation ↗
05Compare the simpler choices
Keep the rule local until it has a reason to travel.
Strategy adds a type for policies and a place to choose one. That pays off when the rule changes from caller to caller while the work around it (checking, copying, settling ties) stays the same.
| Change | Inline comparator or switch | Supplied policy |
|---|---|---|
| One view always uses score | The rule is beside its only use. | A selector may add needless indirection. |
| Two views share validation and ties | Copies can drift as branches change. | The ranker owns shared obligations once. |
| Add a rule using existing fields | Update every branch that decides order. | Add a policy and selection path; preparation stays put. |
| A mode needs network access | A separate operation can state its cost. | The synchronous comparison contract no longer fits. |
Interchangeable means every policy keeps the same promise. A comparison has to be
consistent: the same pair always compares the same way; if a comes before b and b before c,
a comes before c; and “equal” must chain too, so treating scores within 5 points as equal
breaks it (90 ties 86 and 86 ties 82, but 90 beats 82). A policy that reads the clock, or one
that breaks these rules, breaks the sort for every caller, and no ranker can repair it.
JavaScript and Go then return some order; since Rust 1.81, sort_by may panic
when it detects an inconsistent comparator.
Rank before you page. Sorting one downloaded page cannot put the whole result set in order: a paginated service has to apply the chosen policy and the tie rule before it slices.
| Question | This ranker | A reason to choose differently |
|---|---|---|
| A function or an object? | A function: one operation, no state. | An object fits a policy with several related operations, or one that owns a resource. |
| Who chooses, and when? | The caller, once per ranking, by name. | A saved preference or a feature flag may choose instead; check the name where it comes in. |
| Can a policy carry settings? | Not these two (the lab adds one that does). | Capture fixed settings when the policy is built. Keep per-request state out of a policy several callers share. |
| Ranker or policy? | Ties by ID: the ranker. Pinned results: a policy that wraps another. | A rule every caller must get belongs in the ranker. A rule a caller chooses is a policy, even when it wraps another one. |
| Who settles a tie? | The ranker, by ID. | Leave ties to each policy only if callers may see different orders for equal results. |
| Local or remote? | Local and synchronous. | A policy that calls a service adds latency, cancellation and failure to every comparison; rank on the server instead. |
Which rules belong in the ranker? Ask who chooses. A rule every caller must get, such as settling ties or hiding deprecated pages, goes in the ranker, so no policy can forget it. A rule a caller chooses, such as pinning results, is a policy, even when it wraps another one.
When a comparison needs something expensive or changing, such as a value from a service or the current time, compute it once per ranking, before the sort, and compare the results. Never fetch or read the clock inside the comparison itself.
Three neighbours are easy to confuse with it. Factory decides which object to create; Strategy decides which behavior a workflow calls. Template Method fixes the workflow in a base class and lets subclasses fill in steps, where Strategy passes the varying step in. State swaps behavior as an object’s own state changes; with Strategy, the caller makes the choice.
06Decide what may vary
Which decision should be allowed to vary?
Before you open the answer below, say it in your own words: what does the policy decide, what stays with the ranker when the caller changes its policy, and when is the policy worth naming?
One way to say itThree owners, and when the boundary pays
The policy decides one thing: how two results compare under one rule. The ranker owns what every policy relies on, validating, copying and settling ties, so no policy can forget them. The caller owns the choice. For one search view with one obvious rule, keep the comparator local. When the search page and the palette need different rankings while sharing validation, copying and ties, pass the chosen policy to a common ranker. Whatever the policy, it must keep the comparison consistent, or the sort misbehaves for everyone.
Practice in the lab
The documentation search from the lesson: the search page and the command palette rank the same response through one ranker, and the reader picks Relevance or Recent. Each level starts from the previous level’s finished code, so you can begin at any of them.
- STRATEGY-101 (easy) Recent mode ranks deprecated pages above current ones
- STRATEGY-102 (medium) Pin chosen results above any ranking Pro
- STRATEGY-103 (hard) Review: Fresh results come out in a different order each run Pro