Open/closed through the strategy pattern
Put each variant behind one interface and let a registry pick it: new variants arrive beside the core; a switch stays while small.
Predict first
Before you read: predict
A payments ledger keeps one class per payment method in a registry:
A bnpl method (buy now, pay later) arrives next month, written as a new file bnpl.ts in the same shape as card.ts. ledger.test.ts will charge and settle one payment of each method, bnpl included, and compare each answer with that method's class.
The smallest change that compiles under strict and passes that test: which existing files does it edit?
// types.tsexport type MethodId = 'card' | 'bank' | 'wallet';export interface Payment { id: string; method: MethodId; amount: number } // method.tsexport interface PaymentMethod { fee(amount: number): number; settleDays(): number } // card.tsimport type { PaymentMethod } from './method';export class Card implements PaymentMethod { fee(amount: number) { return Math.round(amount * 0.029) + 30; } settleDays() { return 2; }} // bank.tsimport type { PaymentMethod } from './method';export class Bank implements PaymentMethod { fee() { return 25; } settleDays() { return 3; } } // wallet.tsimport type { PaymentMethod } from './method';export class Wallet implements PaymentMethod { fee(amount: number) { return Math.round(amount * 0.015); } settleDays() { return 1; } } // registry.tsimport type { MethodId } from './types';import type { PaymentMethod } from './method';import { Card } from './card';import { Bank } from './bank';import { Wallet } from './wallet';export const methods: Record<MethodId, PaymentMethod> = { card: new Card(), bank: new Bank(), wallet: new Wallet() }; // fees.tsimport type { Payment } from './types';import { methods } from './registry';export const feeFor = (p: Payment): number => methods[p.method].fee(p.amount); // settle.tsimport type { Payment } from './types';import { methods } from './registry';export const settleDaysFor = (p: Payment): number => methods[p.method].settleDays();The 30-second version
A strategy puts each variant in its own class or function behind one interface, and a registry maps each kind to its strategy. A new variant adds a union member, its own file and one registry entry; the code that looks strategies up stays as it is. Type the registry by the union. Keep a small, stable switch.
Put each variant behind one interface and let a registry pick it: new variants arrive beside the core; a switch stays while small.
How it works
Step 1 of 14
In the kitchen
- a job with a new screw head needs one more bit; the screwdriver is never opened → a variant added beside an unchanged core (steps 1, 6)
Analogy: A cordless screwdriver and its bit case (hand tools)
A cordless screwdriver takes interchangeable bits in one hex socket, and a case beside it holds the bits for the job.
| At the workbench | In code |
|---|---|
| the hex socket takes any bit with the standard shank | one interface every strategy implements (step 2) |
| a job with a new screw head needs one more bit; the screwdriver is never opened | a variant added beside an unchanged core (steps 1, 6) |
| a screwdriver with three tips on a turning collar needs a new collar for a fourth head | a switch, reopened by each variant (step 4) |
| the case has a labelled slot for every head on the job list, so a missing bit shows as an empty slot | a registry typed by the union (steps 5, 7) |
| a torque chart taped to the handle, one row per head, needs a row for each new head as well | a second table keyed by the kind (step 9) |
| the person on the job picks the bit; the screwdriver never does | the context does not choose (steps 3, 13) |
| a homeowner who only meets slotted and cross-head screws keeps one two-ended screwdriver | a small, stable switch (step 11) |
In real terms: a strategy is an object or a function the core calls through one interface; the registry is the one place that lists the kinds, and the union is the list the compiler checks it against.
Breaks down when: anyone can see an empty slot. In TypeScript only a registry typed by the union shows a missing entry; a Map hides it until a lookup runs (step 8).
Contrasting case: a workshop that drives one kind of screw all day has nothing to swap: one algorithm needs no strategy.
Examples, run for real
4.1 ExamHall EXH-2550: drag-order in the scoring switch
ExamHall's score() held one switch over the question types. The drag-order type arrived:
// types.ts
export type QuestionType = 'mcq' | 'multi' | 'numeric' | 'essay';
export interface Question { id: string; type: QuestionType; key: string }
// grade.ts
import type { Question } from './types';
export function score(q: Question, answer: string): number {
switch (q.type) {
case 'mcq': return answer === q.key ? 1 : 0;
case 'multi': return answer.split(',').sort().join(',') === q.key ? 2 : 0;
case 'numeric': return Math.abs(Number(answer) - Number(q.key)) <= 0.01 ? 1 : 0;
case 'essay': return 0; // marked by a person later
}
}
4.1 EXH-2550 switch design, drag-order added the smallest way: types.ts 1 line, grade.ts 1 line (a case in score) · grading test 5 of 5
The new type opened grade.ts, the code every type runs through (step 4).
4.2 MR !2850: the scoring switch as a registry
MR !2850 moves each case into a class behind one Scorer interface, registers the classes, and leaves grade.ts a single lookup:
// scorer.ts
import type { Question } from './types';
export interface Scorer { score(q: Question, answer: string): number; }
// mcq.ts (multi.ts, numeric.ts, essay.ts and drag-order.ts hold the other cases the same way)
import type { Scorer } from './scorer';
import type { Question } from './types';
export class Mcq implements Scorer { score(q: Question, answer: string) { return answer === q.key ? 1 : 0; } }
// registry.ts
import type { QuestionType } from './types';
import type { Scorer } from './scorer';
import { Mcq } from './mcq';
// … one import per class
export const scorers: Record<QuestionType, Scorer> = { mcq: new Mcq(), multi: new Multi(), numeric: new Numeric(), essay: new Essay(), 'drag-order': new DragOrder() };
// grade.ts
import type { Question } from './types';
import { scorers } from './registry';
export const score = (q: Question, answer: string): number => scorers[q.type].score(q, answer);
4.2 MR !2850 registry design: grading test 5 of 5, as on the switch (5 of 5) · grade.ts names no question type
4.2 MR !2850 then hotspot added the smallest way: types.ts 1 line, registry.ts 2 lines · new hotspot.ts · grade.ts 0 lines · grading test 6 of 6
The grading tests give the same scores before and after, and the next type, hotspot, arrives beside grade.ts without opening it (steps 5–6).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
ExamHall's proctor console ranks and words each alert in two switches:
More alert kinds are planned, starting with second-face. Each refactor below keeps today's severities and headlines.
Which one lets each later kind be added without editing badge.ts, feed.ts or any existing function?
// types.tsexport type AlertKind = 'tab-switch' | 'no-face' | 'noise';export interface Alert { seat: string; kind: AlertKind } // badge.tsimport type { Alert } from './types';export function severity(a: Alert): number { switch (a.kind) { case 'tab-switch': return 2; case 'no-face': return 3; case 'noise': return 1; }} // feed.tsimport type { Alert } from './types';export function headline(a: Alert): string { switch (a.kind) { case 'tab-switch': return `${a.seat} left the exam tab`; case 'no-face': return `${a.seat}: no face on camera`; case 'noise': return `${a.seat}: noise detected`; }}Question 2 of 5
A shop's product list sorts by price or by name. Its two-case switch in sortProducts has not changed since launch, and no other sort is planned. A teammate's MR replaces it with a SortStrategy interface, two classes and a registry, citing open/closed.
Which review reply holds up?
Question 3 of 5
A video service's transcode() holds one switch over eight codecs, about 400 lines, each case a different way to encode the same segment. Two more codecs are planned this quarter.
Which plan holds up?
Question 4 of 5
A box office prices tickets in one switch:
A box band is planned, and more may follow. Each refactor below keeps today's prices, with one Pricer class per band in its own file.
Which one keeps every later band out of price.ts and makes tsc reject a band that is added to Band with no pricer?
// types.tsexport type Band = 'stalls' | 'circle' | 'balcony';export interface Ticket { seat: string; band: Band; base: number } // price.tsimport type { Ticket } from './types';export function price(t: Ticket): number { switch (t.band) { case 'stalls': return t.base + 12; case 'circle': return t.base + 8; case 'balcony': return t.base; }}Question 5 of 5
A chat app renders each message kind through a registry:
A voice kind is next. Its MR adds voice to Kind, writes voice.ts like text.ts, registers it in kinds.ts and adds the test in voice.test.ts.
Which existing line must the same MR also change?
// types.tsexport type Kind = 'text' | 'image' | 'poll';export interface Message { kind: Kind; body: string } // kind.tsimport type { Message } from './types';export interface MessageKind { render(m: Message): string } // format.tsexport const escape = (s: string): string => s.replace(/</g, '<'); // text.ts (image.ts and poll.ts are built the same way, each calling escape)import type { MessageKind } from './kind';import type { Message } from './types';import { escape } from './format';export class Text implements MessageKind { render(m: Message) { return `<p>${escape(m.body)}</p>`; } } // kinds.tsimport type { Kind } from './types';import type { MessageKind } from './kind';import { Text } from './text';import { Image } from './image';import { Poll } from './poll';export const kinds: Record<Kind, MessageKind> = { text: new Text(), image: new Image(), poll: new Poll() }; // limits.tsexport const maxLength: Record<string, number> = { text: 2000, image: 300, poll: 140 }; // send.tsimport type { Message } from './types';import { kinds } from './kinds';import { maxLength } from './limits';export function send(m: Message): string { if (m.body.length > maxLength[m.kind]) throw new RangeError('too long'); return kinds[m.kind].render(m);} // voice.test.ts (added by the MR)import { send } from './send';test('a voice caption over 120 characters is refused', () => { expect(() => send({ kind: 'voice', body: 'v'.repeat(121) })).toThrow(RangeError);});Go deeper
Pro tips
- When asked how you would add a fourth payment provider, show the line the new provider adds and the test that runs every provider; then say what would make you keep a switch instead. When: the curriculum row's typical question, "We need a fourth payment provider next month. How would you structure the code so that is easy?" Verify: nothing to run; this is interview advice, and it keys nothing. Status: ADVICE.
- Keep one test per kind that checks that kind's own answer: a registry typed by the union still compiles with an entry wired to the wrong class.
When: reviewing a registry after a copy-and-paste MR (step 7). Verify (RAN,
tips.mjs):tip 2 registry typed Record<QuestionType, Scorer> with numeric: new Mcq(): tsc no diagnostics tip 2 one test per question type: 3 of 4 pass · failing: numeric
Source
A web page can change, so every claim from these pages is labelled as of 2026-10-02, the date read; every TypeScript behaviour this lesson grades is also executed on typescript 6.0.3.
- refactoring.guru, Strategy (as of 2026-10-02), the curriculum row's second source: https://refactoring.guru/design-patterns/strategy (
refactoring-guru-strategy.txt, the article from "Intent" to the code-example links). Steps 1, 3 and 10–14 rest on it, and P2 and P3 quote it. - refactoring.guru, Strategy in TypeScript (as of 2026-10-02), the same page's TypeScript example: https://refactoring.guru/design-patterns/strategy/typescript/example (
refactoring-guru-strategy-typescript.txt). Teaching only (step 2): "The Context does not know the concrete class of a strategy. It should work with all strategies via the Strategy interface." It keys no item. - TypeScript Handbook, Utility Types (as of 2026-10-02): https://www.typescriptlang.org/docs/handbook/utility-types.html (
ts-handbook-utility-types.txt). Teaching only (step 7): "Constructs an object type whose property keys are Keys and whose property values are Type". - The row's first source, Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), is a book that cannot be fetched as text, and keys nothing.
Related lessons
- Read first: SOLID at a glance, with LSP and ISP in TypeScript
- Related: Single responsibility as one reason to change
- Related: Dependency inversion and NestJS dependency injection
- Related: any vs unknown vs never
- Related: Narrowing and discriminated unions
- Related: Structural typing, interfaces and declaration merging
- Related: When a pattern is over-engineering
- Related: Composition over inheritance, and the fragile base class
Checked against the tools and docs as of 2026-10-02.