← Design patterns
Behavior Interchangeable policies

Strategy

Change the rule. Keep the workflow.

A search page can put the best match first or show the newest result first. The results stay the same; one ordering rule changes. Give that rule a clear place to live.

TypeScriptGoRust The same ranker in each language.

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.

search.ts
// 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.

ranking.ts
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)}`);
Who owns what
RoleOwnerWhat it does
Caller · suppliesSearch viewOwns the selected mode, the response snapshot, rendering, and visible failure feedback.
Workflow · delegatesResult rankerValidates, copies, invokes the policy, and settles ties by ID.
Policy · decidesOrdering functionCompares 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.

ranking.ts
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

Suppose rankResults passed byRelevance straight to sort, without the ID comparison. In what order do cache and api come out?
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.

results.tsx
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.

How the design responds to plausible changes
ChangeInline comparator or switchSupplied policy
One view always uses scoreThe rule is beside its only use.A selector may add needless indirection.
Two views share validation and tiesCopies can drift as branches change.The ranker owns shared obligations once.
Add a rule using existing fieldsUpdate every branch that decides order.Add a policy and selection path; preparation stays put.
A mode needs network accessA 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.

Decisions the pattern leaves to you
QuestionThis rankerA 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?

The command palette now ranks the same results, and both views must order ties the same way. Where does the tie rule belong?
A settings page lists five feature flags, always alphabetically, and nothing else sorts them. A teammate proposes a SortPolicy type with one AlphabeticalPolicy. What do you suggest?
Two new rules arrive: drafts must never appear in any search, and a team page can boost its own docs when the reader turns that on. Where does each go?

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