Chapter 11

Design patterns in TypeScript

Creational, structural, and behavioural patterns with TypeScript examples.

Design patterns in TypeScript

Design-pattern interviews are rarely about reciting the Gang of Four. They are about whether you can name the force that made a pattern necessary, implement it in the idiom of the language in front of you, and say out loud what it costs. This file gives you all five SOLID principles with a concrete violation and its refactor, all 23 GoF patterns in TypeScript that a modern reviewer would accept, the patterns that are native to TypeScript and JavaScript rather than inherited from C++ and Smalltalk, the anti-patterns interviewers probe for, and a decision table for the moment you are asked “how would you structure this?”. Every implementation here was executed: three consolidated files under /home/claude/scratch/ts-dp/ contain 47 assert-verified pattern demos and type-check clean under tsc --strict --target es2022 --module esnext --moduleResolution bundler --lib es2023. The @ts-expect-error markers are part of the proof — they fail the build if the error they claim does not occur.

The through-line: a language with first-class functions, structural typing, modules, closures, generators and Proxy already provides half of GoF as syntax. Knowing which half is the senior signal.

Table of contents

1. How to talk about patterns

A pattern is a named response to a specific force. If you cannot name the force, you are decorating. The three forces that generate almost all of GoF:

ForceWhat variesPatterns it generates
Creation couplingwhich concrete type gets builtFactory Method, Abstract Factory, Builder, Prototype, Singleton
Structural couplinghow objects are wired and reachedAdapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy
Behavioural couplingwhen and by whom an operation runsthe remaining eleven

Three sentences that make an interview answer sound senior:

  1. “The force here is X” — name what changes at a different rate from everything else.
  2. “In TypeScript that is usually just Y” — map the pattern onto a language feature if one exists.
  3. “The cost is Z” — every pattern buys flexibility with indirection, and indirection is paid for in stack traces, jump-to-definition, and the number of files a newcomer must open.

A pattern taxonomy worth carrying: patterns that survive first-class functions (Composite, Visitor, Flyweight, Proxy, Builder, Mediator, Memento, Interpreter, State-with-data) versus patterns that a function literal replaces (Strategy, Command, Template Method, Observer-in-the-small, Factory Method, Iterator, Singleton). Being able to sort the 23 into those two buckets on the spot is the single highest-yield preparation in this file.

2. SOLID, with a refactoring for each

SOLID is five heuristics from five different authors, retrofitted into an acronym by Michael Feathers. Treat it as a checklist of smells, not a specification. Each subsection below gives the statement, a violation you can point at, the refactor, and the follow-up every good interviewer asks: what did the refactor cost?

2.1 Single responsibility

Statement. A module should have one reason to change — one axis of business volatility, not one method.

Violation. Three unrelated reasons to change in one class: presentation, persistence, delivery.

class InvoiceGod {
  constructor(readonly id: string, readonly cents: number) {}
  render(): string { return `INV ${this.id} $${(this.cents / 100).toFixed(2)}`; }
  persist(rows: string[]): void { rows.push(this.id); }
  notify(outbox: string[]): void { outbox.push(`sent ${this.id}`); }
}

You can smell it before you read it: the class imports a template engine, a database driver and an SMTP client. A change to currency formatting recompiles and redeploys the mail path.

Refactor. The entity becomes data. Each responsibility becomes a collaborator, expressed as the narrowest interface the caller needs.

interface Invoice { readonly id: string; readonly cents: number }

const renderInvoice = (i: Invoice): string => `INV ${i.id} $${(i.cents / 100).toFixed(2)}`;

interface InvoiceStore { save(i: Invoice): void }
interface Mailer { send(to: string, body: string): void }

class InvoiceService {
  constructor(private readonly store: InvoiceStore, private readonly mail: Mailer) {}
  issue(i: Invoice, to: string): void {
    this.store.save(i);
    this.mail.send(to, renderInvoice(i));
  }
}

// Test: two three-line fakes, no database, no SMTP.
const saved: Invoice[] = [];
const sent: string[] = [];
new InvoiceService(
  { save: (i) => void saved.push(i) },
  { send: (_to, body) => void sent.push(body) },
).issue({ id: "A-1", cents: 12_345 }, "a@b.c");
// saved -> [{ id: 'A-1', cents: 12345 }]
// sent  -> ['INV A-1 $123.45']

Q: What does this cost you?

A: Three files where there was one, and a reader now has to hold InvoiceService, InvoiceStore and Mailer in their head simultaneously to answer “what happens when I issue an invoice?”. You also lose the ability to make an invariant hold across render and persist, because nothing owns both any more. Split when the axes genuinely change at different rates; a 40-line class with three cohesive methods is not a god object.

2.2 Open-closed

Statement. You should be able to add behaviour by adding code, not by editing existing code.

Violation. A switch that grows every time the domain grows.

type ShapeBad = { kind: "circle"; r: number } | { kind: "square"; s: number };

function areaBad(s: ShapeBad): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "square": return s.s ** 2;
  }
}

Refactor. Move the per-case logic into a table keyed by the discriminant. Adding a shape adds a row to two type-level maps and one object; the dispatcher never changes.

type ShapeData = {
  circle: { readonly r: number };
  square: { readonly s: number };
  rect: { readonly w: number; readonly h: number };
};
type Shape = { [K in keyof ShapeData]: { readonly kind: K } & ShapeData[K] }[keyof ShapeData];
type AreaFns = { [K in keyof ShapeData]: (s: ShapeData[K]) => number };

const AREA: AreaFns = {
  circle: (s) => Math.PI * s.r ** 2,
  square: (s) => s.s ** 2,
  rect: (s) => s.w * s.h,
};

// The generic parameter is what makes AREA[s.kind] resolve to a single signature.
function area<K extends keyof ShapeData>(s: { readonly kind: K } & ShapeData[K]): number {
  return AREA[s.kind](s);
}

area({ kind: "square", s: 3 }); // 9
area({ kind: "rect", w: 2, h: 5 }); // 10

Two details worth saying out loud. First, AREA is annotated with AreaFns rather than checked with satisfies, because indexing a mapped type with the generic K yields exactly (s: ShapeData[K]) => number, whereas indexing the inferred literal type yields a union of three signatures that TypeScript will refuse to call. Second, Shape is derived from ShapeData, so a new entry cannot be half-added.

Q: What does this cost you?

A: A switch over a closed discriminated union is checked — delete a case and tsc fails on the missing return. A table is checked too, but the indirection costs you a jump: reading area no longer tells you what any shape does. And OCP is a lie for closed domains. There will never be a sixth primitive shape in your renderer; a switch with exhaustiveness checking is the better engineering. Apply OCP where the set is genuinely open — payment providers, file formats, plugin types.

2.3 Liskov substitution

Statement. A subtype must be usable everywhere its supertype is, without the caller knowing. Preconditions may not be strengthened, postconditions may not be weakened, invariants must hold.

Violation. A subtype that narrows the contract by throwing.

class BagOf<T> {
  protected items: T[] = [];
  add(x: T): void { this.items.push(x); }
  size(): number { return this.items.length; }
}
class FrozenBag<T> extends BagOf<T> {
  override add(_x: T): void { throw new Error("immutable"); }
}

Every function typed (b: BagOf<T>) => void is now a landmine. This is the same defect as Collections.unmodifiableList in Java: the type says you can add, the object says otherwise.

Refactor. Split the capability. Readers take the read interface; only writers get the write one.

interface ReadableBag<T> { size(): number; at(i: number): T | undefined }
interface WritableBag<T> extends ReadableBag<T> { add(x: T): void }

const makeBag = <T>(seed: readonly T[] = []): WritableBag<T> => {
  const xs: T[] = [...seed];
  return { size: () => xs.length, at: (i) => xs[i], add: (x) => void xs.push(x) };
};

const total = (b: ReadableBag<number>): number => {
  let t = 0;
  for (let i = 0; i < b.size(); i++) t += b.at(i) ?? 0;
  return t;
};

const b = makeBag([1, 2, 3]);
b.add(4);
total(b); // 10 — and a ReadableBag can be passed where no write is possible

The classic LSP violation to have memorised is Square extends Rectangle; it is worked in full, with a caller that silently computes the wrong answer, in the “inheritance for code reuse” row in section 7.

Q: What does this cost you?

A: Two interfaces to keep in sync, and the loss of “one type to rule them all” in signatures. TypeScript softens the need: readonly T[] and ReadonlyMap/ReadonlySet are already the read-only halves of the built-ins, and Readonly<T> derives one mechanically. Note the deeper point — TypeScript cannot enforce LSP at all, because it only checks shapes. FrozenBag is a perfectly valid subtype to tsc. LSP is a behavioural contract, which means tests, not types.

2.4 Interface segregation

Statement. No client should be forced to depend on methods it does not use.

Violation. One fat port. A read-only consumer now transitively knows about migrations.

interface FatRepo {
  find(id: string): string | undefined;
  insert(id: string, v: string): void;
  delete(id: string): void;
  migrate(): void;
  vacuum(): void;
}

The tell is the test file: to test a function that only calls find, you write four stub methods that throw.

Refactor. Declare the interface at the consumer, not at the implementation. This is where structural typing earns its money: the repository does not have to declare that it implements anything.

interface Reader { find(id: string): string | undefined }
interface Writer { insert(id: string, v: string): void }

const describe = (r: Reader, id: string): string => r.find(id) ?? "<none>";

const db = new Map<string, string>();
const repo = {
  find: (id: string) => db.get(id),
  insert: (id: string, v: string) => void db.set(id, v),
} satisfies Reader & Writer;

repo.insert("k", "v");
describe(repo, "k");    // 'v'   — describe() cannot write, by construction
describe(repo, "nope"); // '<none>'

Q: What does this cost you?

A: Over-applied, ISP produces one interface per method and a Reader & Writer & Deleter & Migrator intersection at every construction site — pure noise. The useful stopping rule: split an interface when a real second consumer wants only part of it, or when the fat interface is making a test painful. Splitting speculatively is premature abstraction with a principle for cover.

2.5 Dependency inversion

Statement. Policy should not depend on mechanism. Both should depend on an abstraction, and the abstraction should be owned by the policy.

Violation. Policy reaches down and constructs a mechanism.

class SystemClock { now(): number { return Date.now(); } }

class ExpiryBad {
  private clock = new SystemClock(); // hard-wired: no test can control time
  expired(at: number): boolean { return this.clock.now() > at; }
}

Refactor. Invert the arrow. In TypeScript the abstraction for a one-method dependency is a function type — no interface, no class, no container.

type Clock = () => number;

const expired = (clock: Clock, at: number): boolean => clock() > at;

expired(() => 100, 50);  // true
expired(() => 100, 500); // false
// production: expired(Date.now, deadline)

type Clock = () => number is a complete dependency-inversion story: policy owns the type, mechanism satisfies it structurally, and the test double is () => 1000.

Q: What does this cost you?

A: Every inverted dependency has to be supplied, so it propagates up the call graph until something owns composition. That is the real cost of DIP: a wiring layer. Done well it is one main.ts. Done badly it is a Provider interface for every collaborator, an abstract factory to build the providers, and a container to build the factories — three layers of indirection so that one day you might swap Postgres for something you will never swap.

2.6 The honest counterpoint

Say this in the interview, because it is true and because it shows judgement:

  • SOLID is guidance shaped by 1990s statically-typed OO. Several principles are partly syntax in TypeScript. DIP for a single-method dependency is a function parameter. ISP is what structural typing does by default — you can accept { find(id: string): string | undefined } inline and never name it.
  • Over-applied DIP produces indirection nobody needs. IUserService with exactly one implementation, UserServiceImpl, is not dependency inversion. It is a rename. If there is one implementation and no test needs a seam, depend on the concrete type and delete the interface — you can extract it in thirty seconds the day a second implementation exists.
  • Over-applied ISP produces interface soup. Interfaces are free to declare and expensive to maintain a matrix of. Six roles crossed with four implementations is 24 conformance relationships to keep straight.
  • OCP fights readability. Every extension point you add is a place where the code no longer says what happens. Exhaustive switch over a discriminated union gives you compiler-enforced completeness, which is often worth more than extensibility you will not use.
  • SRP has no unit. “One responsibility” is unfalsifiable. The operational version — “one reason to change”, i.e. one team, one release cadence, one axis of volatility — is the one to use.

The framing that wins the argument: SOLID is a set of forces, and force is only worth resisting where it acts. Ask “what will change, how often, and who pays when it does?” before invoking any letter.

3. Creational patterns

Creational patterns all answer “who decides which concrete thing gets built, and when?”. In a language with modules, closures and object literals, four of the five collapse considerably.

3.1 Singleton

Intent. Guarantee one instance of a type and provide a global access point to it.

When it earns its keep. When the single instance is enforcing a real physical constraint: one connection pool per process, one write-ahead-log file handle, one metrics registry that would double-count if duplicated. Also for legitimately global immutable data — a parsed config, a compiled schema.

When it is over-engineering. Almost always as a class. In TypeScript a module is already a singleton: ES modules are evaluated once per module specifier per realm, and the module namespace object is cached. class Config { private static instance ... } reimplements the module system with worse ergonomics. And a mutable singleton is a global variable with a nicer hat — see the “singleton as global mutable state” row in section 7.

Implementation. Three levels, from the version an interviewer expects you to know to the version you would actually ship.

// 1. The GoF form. Note that the moment you write a test you are forced to add reset().
class ConfigSingleton {
  private static inst: ConfigSingleton | undefined;
  private constructor(readonly env: string) {}
  static get(): ConfigSingleton {
    return (ConfigSingleton.inst ??= new ConfigSingleton("prod"));
  }
  static reset(): void { ConfigSingleton.inst = undefined; } // the smell, made visible
}

// 2. Idiomatic TypeScript: a module-level const. The module system is the instance cache.
interface AppConfig { readonly env: string; readonly retries: number }
export const config: AppConfig = { env: "test", retries: 3 };

// 3. What you want when construction is expensive and tests need control: a lazy factory value.
const lazy = <T>(make: () => T): (() => T) => {
  let v: T | undefined;
  let done = false;
  return () => {
    if (!done) { v = make(); done = true; }
    return v as T;
  };
};

let built = 0;
const getPool = lazy(() => { built++; return { id: built }; });
getPool() === getPool(); // true
built;                   // 1 — constructed once, on first use

The done flag rather than v === undefined matters: a factory that legitimately returns undefined or null would otherwise re-run forever.

Real-world sightings. Node’s require/ESM module cache. React itself (one module instance, and duplicated copies of React are a famous class of bug). Prisma’s PrismaClient with the documented global-in-dev workaround for hot reload. Angular’s providedIn: 'root'. Python’s logging root logger. The V8 isolate per process. Redux’s single store (one per app, injected via context — not a global).

Interview follow-ups.

Q: How do you test a singleton?

A: You do not; you remove the singleton-ness from the seam under test. Depend on the interface (AppConfig, Clock), inject it, and let composition at the top of the program supply the single instance. If you are stuck with an existing singleton: add a reset() used only by tests, call it in afterEach, and accept that your tests can no longer run in parallel within a worker — which is the concrete price of the pattern.

Q: Is a module-level const really a singleton?

A: Per realm and per resolved specifier, yes. It stops being one across realms — worker threads, vm contexts, iframes each get their own module graph — and it can be duplicated by a bundler if the same package is resolved at two versions or two paths (the dual package hazard). If uniqueness must hold process-wide, key off globalThis with a Symbol.for() registry key.

Q: Double-checked locking in TypeScript?

A: Not needed for synchronous construction: JavaScript is single-threaded per realm, so inst ??= new C() cannot interleave. It is needed for async construction — two callers can both see undefined before either await resolves. Cache the promise, not the value: instP ??= connect().

3.2 Factory Method

Intent. Let a subclass decide which concrete product a template algorithm instantiates.

When it earns its keep. When a framework owns the algorithm and you own the product, and the extension point must be a class because the framework instantiates you. React class components’ render, Iterator’s [Symbol.iterator], and any “extend this base class” plugin API are factory methods in disguise.

When it is over-engineering. When you control both sides. A factory method is a function that returns an object; hoisting it into a subclass hook adds a class hierarchy to express “pass me a constructor”. Pass the function.

Implementation.

interface Transport { send(msg: string): string }

abstract class Notifier {
  protected abstract createTransport(): Transport;   // the factory method
  notify(msg: string): string { return this.createTransport().send(msg); }
}
class SmsNotifier extends Notifier {
  protected override createTransport(): Transport { return { send: (m) => `sms:${m}` }; }
}
class PushNotifier extends Notifier {
  protected override createTransport(): Transport { return { send: (m) => `push:${m}` }; }
}

// The whole hierarchy above, as one line. This is what you should write.
const notifyWith = (make: () => Transport) => (msg: string): string => make().send(msg);

new SmsNotifier().notify("hi");                                  // 'sms:hi'
notifyWith(() => ({ send: (m) => `log:${m}` }))("hi");           // 'log:hi'

Real-world sightings. document.createElement (returns a different class per tag). Node’s http.createServer. Array.from (a factory over anything iterable or array-like). React’s createElement. Every createX in a modern library is a factory function, which is the honest version. Angular’s useFactory provider.

Interview follow-ups.

Q: Factory Method vs Abstract Factory?

A: Cardinality. Factory Method makes one product and varies by subclass; Abstract Factory makes a consistent family of products and varies by object. If you find yourself with createButton and createCheckbox on the same type, you have an Abstract Factory.

Q: Why is a static create() popular even when the constructor works?

A: Constructors cannot be async, cannot return a subtype or a cached instance, cannot fail without throwing, and cannot be named. A static factory can do all five — Foo.fromJSON, Foo.tryParse returning Result, await Client.connect().

Q: Where does new still belong?

A: Wherever the type is not a policy decision: value objects, errors, data structures. new Map() does not need a factory.

3.3 Abstract Factory

Intent. Create families of related objects without naming their concrete classes, guaranteeing the family members are compatible with each other.

When it earns its keep. When mixing families is a bug: a Postgres connection with a MySQL dialect, a dark-theme button with a light-theme checkbox, a testnet signer with a mainnet RPC. The pattern’s real product is the consistency constraint, not the objects.

When it is over-engineering. When there is one family, or when families cannot actually be mixed up. Then it is a namespace with extra steps, and an object literal is clearer.

Implementation. The idiomatic TypeScript form is an object literal typed by the factory interface — no classes at all.

interface Button { render(): string }
interface Checkbox { render(): string }
interface UiKit { button(label: string): Button; checkbox(on: boolean): Checkbox }

const webKit: UiKit = {
  button: (label) => ({ render: () => `<button>${label}</button>` }),
  checkbox: (on) => ({ render: () => `<input type=checkbox ${on ? "checked" : ""}>` }),
};
const tuiKit: UiKit = {
  button: (label) => ({ render: () => `[ ${label} ]` }),
  checkbox: (on) => ({ render: () => (on ? "[x]" : "[ ]") }),
};

// `satisfies` keeps the literal keys ('web' | 'tui') while checking each value is a UiKit.
const KITS = { web: webKit, tui: tuiKit } satisfies Record<string, UiKit>;
type KitName = keyof typeof KITS;

const renderForm = (kit: UiKit): string =>
  `${kit.checkbox(true).render()} ${kit.button("Save").render()}`;

renderForm(KITS.tui); // '[x] [ Save ]'
renderForm(KITS.web); // '<input type=checkbox checked> <button>Save</button>'

The satisfies operator is the point of this example: const KITS: Record<string, UiKit> would erase the keys and let KITS.wbe compile as UiKit. satisfies validates the values and keeps KitName = 'web' | 'tui'.

Real-world sightings. Knex/Kysely dialect objects (each dialect supplies a compatible set of compiler, driver and introspector). React Native’s platform-specific component families. React versus ReactDOM versus react-test-renderer as renderer families. Node’s crypto.webcrypto vs crypto providers. Terraform providers. Intl collator/formatter families per locale.

Interview follow-ups.

Q: How do you stop two families being mixed at compile time?

A: Brand the products. Give each family a phantom type parameter — Button<'web'>, Checkbox<'web'> — and require the consumer to be generic in the family tag. Then webKit.button() and tuiKit.checkbox() cannot meet. See 6.7 Branded types.

Q: Isn’t this just dependency injection?

A: It is a specific shape of DI: injecting one object whose methods construct a coherent set, rather than injecting each product separately. The gain is exactly the coherence guarantee; the loss is that consumers now take a big interface even if they need one product (an ISP tension).

3.4 Builder

Intent. Separate the construction of a complex object from its representation, so the same steps can produce different results and so partial construction is representable.

When it earns its keep. Three cases. (1) Many optional parameters where positional arguments become unreadable — although in TypeScript an options object usually beats a builder outright. (2) A fluent DSL where the sequence matters and you want the types to enforce it: query builders, HTTP request builders, test-data builders. (3) Immutable objects with dozens of fields, built up in stages across functions.

When it is over-engineering. When an options object works. new Pizza().withCheese().withHam().build() is worse than makePizza({ cheese: true, ham: true }): the object literal is checked all at once, gives you excess-property checking, and does not need a build().

Implementation. The version worth showing is the staged builder, where a phantom type parameter tracks which required steps are still missing, so build() is a compile error until the query is well-formed. The trick is to track what is missing (not what is present), because never is assignable to everything and would defeat the check in the other direction.

declare const MISSING: unique symbol;

interface Query {
  readonly table: string;
  readonly cols: readonly string[];
  readonly wheres: readonly string[];
  readonly limit: number | undefined;
}

class QueryBuilder<Missing extends string = "from"> {
  declare readonly [MISSING]: Missing;   // phantom: no runtime footprint
  readonly #q: Partial<Query>;

  private constructor(q: Partial<Query>) { this.#q = q; }

  static create(): QueryBuilder<"from"> {
    return new QueryBuilder<"from">({ cols: [], wheres: [] });
  }
  from(table: string): QueryBuilder<Exclude<Missing, "from">> {
    return new QueryBuilder<Exclude<Missing, "from">>({ ...this.#q, table });
  }
  select(...cols: readonly string[]): QueryBuilder<Missing> {
    return new QueryBuilder<Missing>({ ...this.#q, cols: [...(this.#q.cols ?? []), ...cols] });
  }
  where(pred: string): QueryBuilder<Missing> {
    return new QueryBuilder<Missing>({ ...this.#q, wheres: [...(this.#q.wheres ?? []), pred] });
  }
  take(n: number): QueryBuilder<Missing> {
    return new QueryBuilder<Missing>({ ...this.#q, limit: n });
  }
  // `this` parameter: only reachable once Missing has been emptied.
  build(this: QueryBuilder<never>): string {
    const q = this.#q as Query;
    const cols = q.cols.length ? q.cols.join(", ") : "*";
    const where = q.wheres.length ? ` WHERE ${q.wheres.join(" AND ")}` : "";
    const limit = q.limit === undefined ? "" : ` LIMIT ${q.limit}`;
    return `SELECT ${cols} FROM ${q.table}${where}${limit}`;
  }
}

QueryBuilder.create()
  .select("id", "email")
  .from("users")
  .where("active")
  .where("age > 18")
  .take(10)
  .build();
// 'SELECT id, email FROM users WHERE active AND age > 18 LIMIT 10'

// @ts-expect-error build() is unreachable until from() has been called
QueryBuilder.create().build();

Each step returns a new builder rather than mutating this, which makes a partially-built query safe to share and branch from — the property that turns a builder into a query library.

Real-world sightings. Kysely and Drizzle (both use exactly this phantom-type staging, plus type-level tracking of the selected columns so the result row type is inferred). Knex (fluent but untyped stages). URLSearchParams and URL. Node’s Buffer/stream pipeline chains. fetch’s Request with RequestInit — the options-object alternative. Protobuf and Thrift generated builders. Jest’s expect(...).not.toHaveBeenCalledWith(...) chain.

Interview follow-ups.

Q: Options object or builder?

A: Options object by default: one expression, checked in one place, excess-property checked, trivial to serialise. Builder when construction is incremental across scopes, when order constrains legality, or when you want to attach type-level knowledge as you go (which columns are selected, which auth is attached).

Q: How do you enforce “at least one of a or b” in an options object?

A: A union of exhaustive records: { a: string; b?: never } | { b: string; a?: never }. That also gets you exclusive-or, which a builder does not give you for free.

Q: Why readonly #q rather than private q?

A: private is erased at compile time — (qb as any).q reads it, and it is visible in JSON.stringify. #q is a hard private slot enforced by the runtime, invisible to Object.keys, JSON.stringify and structuredClone. Use # when the invariant matters, private when you just want the API surface documented.

3.5 Prototype

Intent. Create new objects by copying an existing instance rather than by invoking a constructor.

When it earns its keep. When constructing from scratch is expensive or the configuration is only available at runtime: a parsed template you clone per render, a pre-warmed simulation entity, a default document a user then edits. Also when you need copy semantics for undo (see Memento).

When it is over-engineering. As a clone() interface on every class. For plain data, structuredClone (Node 17+, all modern browsers) does deep, cycle-safe copying in one call. For shallow copies with edits, object spread. clone() is worth writing only when the class holds resources you must not copy, or state a generic clone would get wrong.

Implementation.

interface Cloneable<T> { clone(): T }

class Sprite implements Cloneable<Sprite> {
  constructor(
    readonly texture: string,
    readonly pos: { x: number; y: number },
    readonly tags: string[],
  ) {}
  clone(): Sprite { return new Sprite(this.texture, { ...this.pos }, [...this.tags]); }
  withPos(x: number, y: number): Sprite { return new Sprite(this.texture, { x, y }, [...this.tags]); }
}

const proto = new Sprite("hero.png", { x: 0, y: 0 }, ["player"]);
const a = proto.withPos(3, 4);          // prototype untouched
const b = proto.clone();
b.tags.push("mutated");                  // does not leak into proto

// Modern equivalent for plain data: deep, cycle-safe, drops methods and functions.
const deepCopy = <T>(v: T): T => structuredClone(v);

const cyc: { self?: unknown; n: number } = { n: 1 };
cyc.self = cyc;
const copy = deepCopy(cyc);
copy.self === copy;   // true — the cycle is preserved, not expanded
copy === cyc;         // false

Note what clone() buys over structuredClone: control. structuredClone throws on functions, Proxy, DOM nodes and class instances lose their prototype (you get a plain object). It handles Map, Set, Date, RegExp, ArrayBuffer and typed arrays correctly, which JSON.parse(JSON.stringify(x)) does not.

Real-world sightings. JavaScript’s own prototype chain and Object.create — the language is named after this pattern. structuredClone and the HTML structured-clone algorithm used by postMessage and IndexedDB. Immer’s produce (copy-on-write drafts). React.cloneElement. Docker image layers and git object copying, conceptually. Object.assign(Object.create(Object.getPrototypeOf(x)), x) as the “keep the class” trick.

Interview follow-ups.

Q: Deep or shallow?

A: Shallow by default and say why: cheap, and safe if the shared parts are immutable. Deep when the copy will be mutated independently. The bug to name is the half-deep copy — spread the outer object, share the inner array, then mutate the array.

Q: structuredClone vs JSON.parse(JSON.stringify(x))?

A: JSON round-tripping loses undefined, Map, Set, Date (becomes a string), BigInt (throws), Infinity/NaN (become null), and infinite-loops on cycles. structuredClone handles all of those except functions and Symbol, and preserves cycles and identity sharing.

Q: Where does Object.create(proto) still matter?

A: Prototype-chain delegation without classes, and Object.create(null) for a dictionary with no inherited keys — the correct way to build a lookup map if you are not using Map.

3.6 Verified output — creational

creational.ts
  ok  SRP: split render / persist / notify
  ok  OCP: dispatch table keyed by discriminant
  ok  LSP: capability split instead of throwing override
  ok  ISP: narrow ports at the consumer
  ok  DIP: inject a Clock function, not a SystemClock class
  ok  Singleton: static instance, module const, lazy factory
  ok  Factory Method: subclass hook and its function equivalent
  ok  Abstract Factory: two consistent product families as object literals
  ok  Builder: staged builder, build() gated by a phantom type
  ok  Prototype: clone() plus structuredClone for plain data
  ok  Module/closure: private state via closure and via #fields
  ok  Branded types: nominal ids with zero runtime cost
  ok  Result: Ok/Err with map, andThen, unwrapOr
  ok  DI: constructor, typed container, plain function params
  ok  Object Pool: acquire/release with reset and reuse
  15 pattern groups verified

4. Structural patterns

Structural patterns are about the shape of the object graph: what wraps what, what stands in for what, what shares with what. Four of them wrap another object with the same or similar interface (Adapter, Decorator, Facade, Proxy) and the difference between them is intent, not code — 4.8 is the answer to the question every interviewer asks.

4.1 Adapter

Intent. Convert the interface of an existing type into the interface a client expects.

When it earns its keep. At every boundary you do not own: a vendor SDK, a legacy module, a window.fetch you want to look like your HttpClient, two libraries with the same concept and different names. The adapter is the only place in your codebase that knows the vendor’s vocabulary, which makes vendor replacement a single-file change.

When it is over-engineering. Never really — but the class usually is. In TypeScript an adapter is one object literal.

Structure.

   client ──uses──▶ Target (your interface)

                       │ implements
                  Adapter ──holds──▶ Adaptee (vendor, unchangeable)

Implementation.

// The vendor API we cannot change.
class LegacyPrinter {
  readonly out: string[] = [];
  printOut(text: string, copies: number): void {
    for (let i = 0; i < copies; i++) this.out.push(text);
  }
}

// The interface our code wants.
interface Sink { write(line: string): void }

// Object adapter: holds the adaptee.
class PrinterAdapter implements Sink {
  constructor(private readonly legacy: LegacyPrinter) {}
  write(line: string): void { this.legacy.printOut(line, 1); }
}

// Function adapter: identical behaviour, one expression. Prefer this.
const asSink = (legacy: LegacyPrinter): Sink => ({ write: (l) => legacy.printOut(l, 1) });

const emit = (s: Sink, xs: readonly string[]): void => { for (const x of xs) s.write(x); };

const p = new LegacyPrinter();
emit(asSink(p), ["a", "b"]);
p.out; // ['a', 'b']

Real-world sightings. @aws-sdk v2-to-v3 shims. node-fetch and undici presenting the WHATWG fetch interface over Node internals. TypeORM/Prisma/Kysely dialect drivers. React’s synthetic event system adapting native events. Array.from(nodeList) adapting array-like to array. Testing Library’s framework adapters (@testing-library/react vs /vue) over one core. Redux middleware adapting promises/observables to plain actions.

Interview follow-ups.

Q: Class adapter vs object adapter?

A: A class adapter inherits from the adaptee; an object adapter composes it. JavaScript has no multiple inheritance, so object adaptation is the only general option — and it is the better one anyway, because it can adapt an instance you were handed rather than one you constructed.

Q: Where do you put the adapter?

A: In the module that owns the target interface, not the one that owns the vendor. That keeps the dependency arrow pointing from mechanism to policy, which is DIP.

Q: Two-way adapters?

A: Sometimes needed at protocol boundaries (a codec). Model it as two functions, toWire/fromWire, and property-test fromWire(toWire(x)) === x — round-trip is the invariant that catches real bugs.

4.2 Bridge

Intent. Split an abstraction from its implementation so both can vary independently, avoiding a combinatorial class explosion.

When it earns its keep. When you have two independent axes of variation and the naive design multiplies them. Three shapes and four renderers is 12 classes as a hierarchy, 3 + 4 as a bridge. The tell is class names with two nouns in them: SvgCircle, CanvasCircle, SvgRect, CanvasRect.

When it is over-engineering. With one implementation, or when the axes are not actually independent (if every shape needs renderer-specific special-casing, the bridge leaks and you get if (renderer instanceof Svg) back).

Structure.

   Abstraction (Widget)            Implementor (Renderer)
        │  holds ──────────────────────▶ │
   ┌────┴────┐                      ┌────┴────┐
  Dot     Caption                  svg      ascii

   2 abstractions x 2 implementors = 4 behaviours from 4 declarations,
   not 4 classes; add a renderer and you add 1 thing, not N.

Implementation.

interface Renderer {
  circle(r: number): string;
  label(text: string): string;
}
const svg: Renderer = {
  circle: (r) => `<circle r="${r}"/>`,
  label: (t) => `<text>${t}</text>`,
};
const ascii: Renderer = {
  circle: (r) => `O(${r})`,
  label: (t) => `"${t}"`,
};

abstract class Widget {
  constructor(protected readonly r: Renderer) {}
  abstract draw(): string;
}
class Dot extends Widget {
  constructor(r: Renderer, private readonly radius: number) { super(r); }
  override draw(): string { return this.r.circle(this.radius); }
}
class Caption extends Widget {
  constructor(r: Renderer, private readonly text: string) { super(r); }
  override draw(): string { return this.r.label(this.text); }
}

new Dot(svg, 4).draw();          // '<circle r="4"/>'
new Dot(ascii, 4).draw();        // 'O(4)'
new Caption(ascii, "hi").draw(); // '"hi"'

Real-world sightings. React’s reconciler versus its renderers (react-dom, react-native, react-three-fiber, Ink) — the canonical modern Bridge. Slate/ProseMirror document model versus DOM view. Node’s stream abstraction over fs/net/zlib implementations. SLF4J and debug-style logging facades over backends. JDBC/ODBC. Vue’s runtime-core versus runtime-dom.

Interview follow-ups.

Q: Bridge vs Strategy — they look identical.

A: The code is nearly the same; the intent and lifetime differ. Strategy swaps one algorithm inside one object, often per call, and is usually a function. Bridge separates an entire implementation hierarchy from an abstraction hierarchy, is chosen once at construction, and both sides are expected to grow. If only one side ever grows, you have Strategy.

Q: Bridge vs Adapter?

A: Adapter is retrofitted to make two existing incompatible things fit. Bridge is designed up front to keep two things from becoming coupled.

4.3 Composite

classDiagram
    class Component {
        <<interface>>
        +name string
        +size() number
    }
    class FileC {
        +name string
        -bytes number
        +size() number
    }
    class DirC {
        +name string
        +children List~Component~
        +add(c: Component) DirC
        +size() number
    }
    Component <|.. FileC : implements
    Component <|.. DirC : implements
    DirC o-- Component : children

Intent. Let clients treat individual objects and compositions of objects uniformly.

When it earns its keep. Any recursive domain: file systems, UI trees, ASTs, org charts, permission groups, nested invoices, arithmetic expressions. The value is that the client’s code has no special case for leaves.

When it is over-engineering. When the tree is two levels deep and always will be, or when leaves and composites really do need different handling — then a “uniform” interface just moves the if into every method as if (this.children.length === 0).

Structure.

              Component
             (size(): number)
              ▲          ▲
       ┌──────┘          └──────┐
     Leaf                   Composite ──children──▶ Component[]
   (bytes)                 (sum of children)                 └─┐
                                                     recursion ◀┘

Implementation. In TypeScript the idiomatic form is a recursive discriminated union, not a class hierarchy: the data is plain, serialisable, and every fold over it is exhaustiveness-checked.

type FsNode =
  | { readonly kind: "file"; readonly name: string; readonly bytes: number }
  | { readonly kind: "dir"; readonly name: string; readonly children: readonly FsNode[] };

const totalBytes = (n: FsNode): number =>
  n.kind === "file" ? n.bytes : n.children.reduce((acc, c) => acc + totalBytes(c), 0);

const paths = (n: FsNode, prefix = ""): readonly string[] => {
  const here = `${prefix}/${n.name}`;
  return n.kind === "file" ? [here] : n.children.flatMap((c) => paths(c, here));
};

const tree: FsNode = {
  kind: "dir", name: "src",
  children: [
    { kind: "file", name: "a.ts", bytes: 100 },
    { kind: "dir", name: "lib", children: [{ kind: "file", name: "b.ts", bytes: 250 }] },
  ],
};
totalBytes(tree); // 350
paths(tree);      // ['/src/a.ts', '/src/lib/b.ts']

The class version is still the right answer when nodes have identity, mutable state, or behaviour that belongs with the node (a UI component that can focus itself):

interface Component { readonly name: string; size(): number }

class FileC implements Component {
  constructor(readonly name: string, private readonly bytes: number) {}
  size(): number { return this.bytes; }
}
class DirC implements Component {
  readonly children: Component[] = [];
  constructor(readonly name: string) {}
  add(c: Component): this { this.children.push(c); return this; }
  size(): number { return this.children.reduce((a, c) => a + c.size(), 0); }
}

new DirC("src")
  .add(new FileC("a.ts", 100))
  .add(new DirC("lib").add(new FileC("b.ts", 250)))
  .size(); // 350

Real-world sightings. The DOM. React element trees. The TypeScript compiler’s Node hierarchy. fs directory trees. CSS box model. Yoga/Flexbox layout trees. Composite pattern in permission systems (a group is a principal). Every JSON document.

Interview follow-ups.

Q: Where do add/remove go — on Component or only on Composite?

A: GoF put them on Component for uniformity, and that is an LSP violation: file.add(x) has to throw. Put them on the composite. Clients that need to add already know they hold a container, or you narrow with a type guard.

Q: How do you avoid stack overflow on deep trees?

A: Convert the recursion to an explicit stack. let stack = [root]; while (stack.length) { const n = stack.pop()!; ... }. Node’s default stack handles roughly 10⁴ frames; a 50k-deep linked structure (a degenerate AST from generated code, for example) will blow it.

Q: How do you cache size() safely?

A: Only if the tree is immutable. With the union version, memoise on the node object with a WeakMap<FsNode, number> — no field on the data, and entries collect when the tree does.

4.4 Decorator

Intent. Add responsibilities to an object dynamically, without changing its type, by wrapping it in something with the same interface.

When it earns its keep. Cross-cutting concerns that compose and whose order matters: caching, logging, retry, timing, authorisation, rate limiting, tracing. The key property is that every layer has the same interface, so layers commute in any order you choose and the client is unaware.

When it is over-engineering. When there is exactly one wrapper forever. Then inline it. Also beware deep stacks: eight decorators make a stack trace unreadable and a console.log ambiguous.

Structure.

  client ──▶ withTrace ──▶ withPrefix ──▶ withCache ──▶ base
              (same interface at every arrow: Store)

  call:   get('x')  ─────────────────────────────────▶
  return  ◀───────────────────────────────────────────
  each layer sees the call on the way in and the value on the way out

Implementation. Functions returning objects. No classes, no super, and the composition reads outside-in.

interface Store { get(key: string): string }

const base = (hits: string[]): Store => ({
  get: (k) => { hits.push(k); return `v:${k}`; },
});

const withCache = (inner: Store): Store => {
  const memo = new Map<string, string>();
  return {
    get: (k) => {
      let v = memo.get(k);
      if (v === undefined) { v = inner.get(k); memo.set(k, v); }
      return v;
    },
  };
};
const withPrefix = (inner: Store, p: string): Store => ({ get: (k) => `${p}${inner.get(k)}` });
const withTrace = (inner: Store, log: string[]): Store => ({
  get: (k) => { log.push(`-> ${k}`); const v = inner.get(k); log.push(`<- ${v}`); return v; },
});

const hits: string[] = [];
const log: string[] = [];
const store = withTrace(withPrefix(withCache(base(hits)), "p/"), log);

store.get("x"); // 'p/v:x'
store.get("x"); // 'p/v:x'
hits;           // ['x']  — the cache layer absorbed the second call
log;            // ['-> x', '<- p/v:x', '-> x', '<- p/v:x']

Order is semantics, not style. withPrefix(withCache(base)) caches the unprefixed value; withCache(withPrefix(base)) caches the prefixed one. Say this when asked.

TypeScript also has decorator syntax, which is a different thing — ECMAScript decorators (Stage 3, supported natively by TypeScript 5.0+ without experimentalDecorators) are a metaprogramming facility for annotating class members, and the legacy experimentalDecorators form is what Angular, NestJS and TypeORM use. They are frequently used to implement the Decorator pattern (a @Cached() method decorator) but they can equally register metadata, which is not the pattern at all.

Real-world sightings. Express/Koa middleware (Decorator over a request handler — see 6.10 Async patterns). Object.freeze-style wrappers. Node streams (gzip.pipe(cipher).pipe(file)). React higher-order components and React.memo. RxJS operators. NestJS/Angular @Injectable, @Controller — decorator syntax. Python’s functools.lru_cache, the same idea with sugar. axios interceptors.

Interview follow-ups.

Q: Decorator vs inheritance for adding behaviour?

A: Decorator composes at runtime and can be applied N times in any order to any instance; inheritance is fixed at compile time and single-parent. If you can enumerate the combinations (CachedLoggedRetryingStore), you have proven the point — that class name is the combinatorial explosion decorators avoid.

Q: How do you keep this working when decorating a class instance?

A: Do not hand out unbound methods. Wrap with an arrow-function object literal (as above), or use a Proxy with a get trap that binds. const wrapped = { get: (k) => svc.get(k) } is safe; { get: svc.get } is not.

Q: How do you decorate every method of an interface without writing each one?

A: A Proxy with a get trap that returns a wrapped function, typed as <T extends object>(t: T, wrap: (fn: Function, key: string) => Function) => T. It is the right tool for uniform cross-cutting concerns and the wrong tool when only two of nine methods need wrapping.

4.5 Facade

Intent. Provide one simplified interface to a subsystem of many parts.

When it earns its keep. When correct use of a subsystem requires knowing an ordering, a set of defaults, or a cleanup discipline, and 95% of callers want the same happy path. A facade encodes the “how to use this correctly” knowledge in code instead of a wiki page.

When it is over-engineering. When it becomes the only way in and starts growing a parameter for every underlying option — that is a god object with a nicer name. A good facade leaves the parts public for the 5% who need them.

Implementation. A facade is very often just a function.

class Demuxer { split(f: string): readonly string[] { return [`${f}#video`, `${f}#audio`]; } }
class Codec { transcode(stream: string, to: string): string { return `${stream}->${to}`; } }
class Muxer { join(streams: readonly string[]): string { return streams.join("+"); } }

// One coarse entry point that hides the wiring and the ordering rules.
function convert(file: string, format: string): string {
  const streams = new Demuxer().split(file);
  const codec = new Codec();
  return new Muxer().join(streams.map((s) => codec.transcode(s, format)));
}

convert("clip.mov", "mp4"); // 'clip.mov#video->mp4+clip.mov#audio->mp4'

Real-world sightings. fetch over XHR/HTTP internals. jQuery over the DOM (the original mass-market facade). fs.promises.readFile over open/read/close. Prisma Client over connection pooling + SQL + row mapping. @testing-library/user-event over dispatchEvent sequences. FFmpeg’s CLI over libav. npm install over the resolver, fetcher, linker and lifecycle scripts.

Interview follow-ups.

Q: Facade vs Adapter?

A: Adapter changes an interface to match an expected one — same scope, different shape. Facade reduces the surface of many objects into one — different scope, simpler shape. Adapter is driven by an interface you must satisfy; Facade by a use case you want to make easy.

Q: Should a facade be stateless?

A: Prefer it. A stateless facade is a function and composes trivially. A stateful facade (new Client(config)) is fine when the state is a resource — a pool, a socket — and then it should implement Symbol.dispose (or expose close()) so callers can release it.

4.6 Flyweight

Intent. Share identical immutable state between many objects so that per-object memory stays small. Split state into intrinsic (shared, in the flyweight) and extrinsic (per use, passed in).

When it earns its keep. Genuinely large N with genuinely repeated state: glyph metrics for a text engine, tile types in a tilemap, interned strings/symbols in a compiler, cached immutable value objects (Temporal durations, currency instances), tokens in a lexer. The gain is both memory and cache locality, plus reference equality becomes a valid fast path.

When it is over-engineering. Below tens of thousands of instances, the Map you added costs more than the objects you saved, and you have introduced a lifetime question (when does the cache release?). Also lethal if the “immutable” flyweight turns out to be mutable — one write corrupts every user.

Implementation.

interface Glyph { readonly char: string; readonly widthEm: number }

class GlyphCache {
  readonly #cache = new Map<string, Glyph>();
  #misses = 0;
  get(char: string): Glyph {
    let g = this.#cache.get(char);
    if (g === undefined) {
      this.#misses++;
      g = Object.freeze({ char, widthEm: char === "i" ? 0.3 : 0.6 });
      this.#cache.set(char, g);
    }
    return g;
  }
  get distinct(): number { return this.#cache.size; }
  get misses(): number { return this.#misses; }
}

// Extrinsic state (position, colour) would live in the layout result, not the glyph.
const layout = (cache: GlyphCache, text: string) => {
  const glyphs = [...text].map((c) => cache.get(c));
  return { glyphs, width: glyphs.reduce((a, g) => a + g.widthEm, 0) };
};

const cache = new GlyphCache();
const a = layout(cache, "mississippi");
a.glyphs.length;            // 11 slots
cache.distinct;             // 4 objects: m i s p
a.glyphs[1] === a.glyphs[4];// true — both 'i' are the same frozen object
layout(cache, "sip");
cache.misses;               // still 4 — no new allocations

Real-world sightings. JavaScript string interning in V8 (identical literals share one InternalizedString, which is why 'a' === 'a' is a pointer compare). Symbol.for()’s global symbol registry — a flyweight factory in the language. React element reuse and React.memo/useMemo returning the same object so referential-equality bailouts fire. Number small-integer caches in Python/Java. Font glyph atlases. Immutable.js structural sharing. Emoji/sprite atlases in games.

Interview follow-ups.

Q: What breaks if the flyweight is mutable?

A: Everything, silently and non-locally: one caller’s write is every caller’s read. Object.freeze it, or make it a primitive/readonly record. In TypeScript readonly alone is a compile-time promise only; Object.freeze is the runtime guarantee.

Q: How does the cache release memory?

A: Either it does not (bounded intrinsic domains — 128 tile types, 65k code points, fine) or you need eviction. WeakRef + FinalizationRegistry gives you a cache that lets values die, and WeakMap/WeakSet gives you keyed side-tables that die with their keys. An LRU with a hard cap is usually the pragmatic answer.

Q: Is Map or a plain object the right cache?

A: Map for arbitrary/dynamic keys: no prototype pollution, size in O(1), any key type, and better behaviour under frequent add/delete. A plain object literal only when the key set is fixed and small enough for V8 to keep a stable hidden class.

4.7 Proxy

Intent. Provide a surrogate with the same interface as a real object, in order to control access to it.

When it earns its keep. Four classic sub-species, all still relevant: virtual (defer expensive construction until first use), protection (validate/authorise), remote (a local stand-in for something over a wire), and caching/smart reference. JavaScript’s Proxy makes all four cheap because the traps are generic — you do not have to enumerate the interface.

When it is over-engineering. When you know which members you need — then a plain wrapper object is faster, debuggable and typed. Proxy defeats V8’s inline caches and hidden-class optimisations; every trapped access is a megamorphic call. It also breaks structuredClone, confuses instanceof in subtle cases, and can make console.log lie.

Structure.

  client ──▶ Proxy ─────(traps: get/set/has/apply)────▶ RealSubject

              └─ may: create it lazily, deny the call, log it,
                 batch it, memoise it, or forward it over a socket

Implementation. Three of the four species, in 30 lines.

// (1) Virtual proxy: nothing is constructed until a property is touched.
const lazyProxy = <T extends object>(init: () => T): T => {
  let real: T | undefined;
  const load = (): T => (real ??= init());
  return new Proxy({} as T, {
    get: (_t, p) => Reflect.get(load(), p),
    has: (_t, p) => p in load(),
    ownKeys: () => Reflect.ownKeys(load()),
    getOwnPropertyDescriptor: (_t, p) => Reflect.getOwnPropertyDescriptor(load(), p),
  });
};

// (2) Protection proxy: validate writes.
type Rules<T> = Partial<Record<keyof T, (v: unknown) => boolean>>;
const guarded = <T extends object>(target: T, rules: Rules<T>): T =>
  new Proxy(target, {
    set(obj, prop, value, recv) {
      const rule = rules[prop as keyof T];
      if (rule && !rule(value)) throw new TypeError(`invalid value for ${String(prop)}`);
      return Reflect.set(obj, prop, value, recv);
    },
  });

// (3) Tracking proxy: what Vue's reactivity and Immer both do at heart.
const tracked = <T extends object>(target: T, reads: string[]): T =>
  new Proxy(target, {
    get(obj, prop, recv) { reads.push(String(prop)); return Reflect.get(obj, prop, recv); },
  });

let built = 0;
const heavy = lazyProxy(() => { built++; return { rows: [1, 2, 3], name: "big" }; });
built;                        // 0 — nothing constructed yet
heavy.name;                   // 'big'
built;                        // 1, and stays 1
Object.keys(heavy).sort();    // ['name','rows'] — ownKeys trap makes enumeration work

const conf = guarded({ port: 80, host: "a" }, { port: (v) => typeof v === "number" && v > 0 });
conf.port = 8080;             // fine
// conf.port = -1;            // TypeError: invalid value for port

const reads: string[] = [];
const state = tracked({ a: 1, b: 2 }, reads);
void (state.a + state.a + state.b);
reads;                        // ['a','a','b'] — this is a dependency graph

Reflect.* inside a trap is not decoration: Reflect.set(obj, prop, value, recv) forwards the correct receiver so setters on the prototype chain see the proxy as this. Hand-rolling obj[prop] = value inside a set trap is the classic proxy bug.

The ownKeys trap without getOwnPropertyDescriptor is another: Object.keys calls both, and a key reported by ownKeys whose descriptor is not enumerable is silently dropped.

Real-world sightings. Vue 3 reactivity (reactive() is a Proxy with get/set traps building a dependency graph — exactly example 3). Immer’s copy-on-write drafts. MobX 6. Prisma’s fluent client. @apollo/client optimistic caches. Node’s --experimental-vm-modules module namespace exotic objects. process.env on Windows (case-insensitive via traps). ORM lazy-loaded relations. SolidJS stores. comlink (remote proxy over postMessage — the Remote Proxy species, alive and well).

Interview follow-ups.

Q: What does a Proxy cost at runtime?

A: Every trapped operation is a call into JS with allocation of a receiver and a property key, and it poisons the inline cache for that access site — expect an order of magnitude on hot property reads. Vue’s answer is to proxy only state objects, not every value, and to memoise the proxy per target in a WeakMap.

Q: Can you type a Proxy honestly?

A: Only by lying carefully. new Proxy({} as T, handler) as T asserts a shape the target does not have. That is acceptable inside a small, well-tested factory whose signature is honest (lazyProxy<T>(init: () => T): T), and unacceptable spread through application code.

Q: Proxy vs Object.defineProperty?

A: defineProperty (Vue 2’s approach) must enumerate keys up front, so it cannot see new properties, array index writes, or delete. Proxy intercepts the operation itself, which is why Vue 3 rewrote on it. The cost is no IE11 and no polyfill possible.

Q: Which traps must agree with each other?

A: The invariants are enforced by the spec: ownKeys must not report duplicates and must report all non-configurable own keys of the target; get must return the target’s value for non-configurable, non-writable data properties; has must not hide a non-configurable property. Violating one throws a TypeError at the operation, not at proxy construction — which is a nasty debugging experience.

4.8 The four wrappers, distinguished

This is the highest-frequency structural question in interviews. All four hold a reference to another object and expose a similar interface. The difference is why.

PatternInterface vs wrappeePurposeClient knows?Instances
AdapterDifferent — convertsMake an incompatible thing fitYes (asked for the target type)Usually 1 per vendor
DecoratorSame — preservesAdd behaviour, composablyNoN, stacked, order matters
FacadeDifferent — narrows over many objectsSimplify a subsystemYes (chose the easy door)1, plus the parts still exposed
ProxySame — identicalControl access: lazy, guard, remote, cacheNo (that is the point)1, and it is a stand-in

Discriminating questions to ask yourself:

  • Did the interface change? Adapter or Facade. Same interface? Decorator or Proxy.
  • One wrappee or many? One → Adapter/Decorator/Proxy. Many → Facade.
  • Does the wrapper add domain behaviour or access control? Behaviour → Decorator. Control → Proxy.
  • Would you stack two of them? Decorator, always. Proxy, rarely. Adapter, never.

One sentence that lands: “Decorator adds to what the object does; Proxy controls whether and when the object does it at all; Adapter changes how you ask; Facade changes how many things you have to ask.”

4.9 Verified output — structural

structural.ts
  ok  Adapter: class adapter and one-line function adapter
  ok  Bridge: abstraction and implementation vary independently
  ok  Composite: recursive union and class hierarchy agree
  ok  Decorator: composable wrappers, same interface at every layer
  ok  Facade: one function over three subsystems
  ok  Flyweight: 11 glyph slots backed by 4 shared objects
  ok  Proxy: virtual, protection and tracking proxies
  ok  Mixins: two class factories composed onto one base
  ok  Repository + Unit of Work: staged writes, commit and rollback
  ok  Null Object for behaviour, Option for values
  ok  Immutability: path copying keeps untouched subtrees identical
  ok  LSP: Square/Rectangle breaks callers; union of values does not
  ok  Primitive obsession: parse at the boundary into branded values
  ok  Service locator hides deps and fails late; DI fails at compile time
  14 pattern groups verified

5. Behavioural patterns

Behavioural patterns are where TypeScript diverges most from the GoF book, because first-class functions and discriminated unions replace whole class hierarchies. For each one: what it is for, and what it actually collapses to in modern TypeScript.

5.1 Chain of Responsibility

Intent: pass a request along a chain of handlers until one deals with it.

Earns its keep when the set of handlers is configured at runtime (middleware stacks, validation pipelines, log-level filters). Over-engineering when the chain is fixed and short — that is a switch or a sequence of ifs.

The class-based GoF version (each handler holding a next pointer) is almost never what you want in JavaScript. The idiomatic form is composed middleware, which is exactly the Express/Koa shape:

request ──> auth ──> rateLimit ──> approve ──> "handled"
              │         │
              └─ short-circuits with its own response
type Handler<T> = (req: T, next: () => string) => string;

function chain<T>(...hs: Handler<T>[]): (req: T) => string {
  return (req) => {
    const dispatch = (i: number): string =>
      i === hs.length ? 'unhandled' : hs[i]!(req, () => dispatch(i + 1));
    return dispatch(0);
  };
}

type Payment = { user?: string; amount: number };
const auth: Handler<Payment>    = (r, next) => (r.user ? next() : 'reject: anonymous');
const limit: Handler<Payment>   = (r, next) => (r.amount > 1000 ? 'reject: over limit' : next());
const approve: Handler<Payment> = () => 'approved';

const pipeline = chain(auth, limit, approve);
pipeline({ user: 'ana', amount: 50 });     // 'approved'
pipeline({ amount: 50 });                  // 'reject: anonymous'
pipeline({ user: 'ana', amount: 5000 });   // 'reject: over limit'

Each handler decides whether to call next(), which is both the “pass it on” and the “short-circuit” mechanism. Note that dispatch is closed over per invocation, so the pipeline is reentrant — a class-based chain with mutable next pointers is not.

Sightings: Express/Koa/Connect middleware, Redux middleware, Node’s stream.pipeline, Axios interceptors, ASP.NET’s request pipeline. Also DOM event bubbling, which is a chain you do not build.

Q: How is this different from the Decorator pattern?

A: A decorator always delegates and always adds behaviour; a chain handler may stop the request. Structurally similar, intent different.

Q: How do you make it async?

A: type Handler<T> = (req: T, next: () => Promise<R>) => Promise<R> and await dispatch(i + 1). That is precisely Koa’s compose.

5.2 Command

Intent: turn a request into an object, so it can be queued, logged, or undone.

Earns its keep when you need undo/redo, a queue of deferred work, or an audit log of intents. Over-engineering for “I want to pass behaviour around” — that is a closure.

interface Command { execute(): void; undo(): void; readonly label: string }

class Editor {
  text = '';
  #history: Command[] = [];
  run(c: Command) { c.execute(); this.#history.push(c); }
  undo() { this.#history.pop()?.undo(); }
}

// A factory returning a closure over the receiver — no class per command.
const insertCmd = (ed: Editor, s: string): Command => ({
  label: `insert ${s}`,
  execute() { ed.text += s; },
  undo() { ed.text = ed.text.slice(0, -s.length); },
});

const ed = new Editor();
ed.run(insertCmd(ed, 'hello'));
ed.run(insertCmd(ed, ' world'));   // 'hello world'
ed.undo();                          // 'hello'

The pattern’s real content is the undo half — that is what a closure alone does not give you. If you only need execute, a function is the whole pattern.

Sightings: every editor’s undo stack, Redux actions (serializable commands, with the reducer as the receiver), database transaction logs, job queues (BullMQ payloads are commands), CQRS.

Q: Command vs Strategy?

A: Both wrap behaviour. Strategy is how to do one thing, swapped at a decision point. Command is what to do, stored and replayed. Strategy has no lifecycle; Command has queue/undo/log.

Q: How do you implement redo?

A: Two stacks. undo pops from the undo stack and pushes to the redo stack; any new command clears the redo stack.

5.3 Interpreter

Intent: define a grammar and evaluate sentences in it.

Earns its keep for small DSLs: filter expressions, query builders, feature-flag rules, spreadsheet formulas. Over-engineering if a real parser generator or an existing expression library fits, and never for a general-purpose language.

In TypeScript the GoF class-per-node design is replaced by a discriminated union AST plus one fold per operation, which gives you exhaustiveness checking for free:

type Expr =
  | { kind: 'num'; value: number }
  | { kind: 'var'; name: string }
  | { kind: 'add'; left: Expr; right: Expr }
  | { kind: 'mul'; left: Expr; right: Expr };

const evaluate = (e: Expr, env: Record<string, number>): number => {
  switch (e.kind) {
    case 'num': return e.value;
    case 'var': return env[e.name] ?? 0;
    case 'add': return evaluate(e.left, env) + evaluate(e.right, env);
    case 'mul': return evaluate(e.left, env) * evaluate(e.right, env);
    default: { const _e: never = e; return _e; }     // add a node kind -> this line errors
  }
};

const show = (e: Expr): string => { /* same shape, different fold */ };

const ast: Expr = { kind: 'mul',
  left: { kind: 'add', left: { kind: 'num', value: 2 }, right: { kind: 'var', name: 'x' } },
  right: { kind: 'num', value: 3 } };
evaluate(ast, { x: 4 });   // 18
show(ast);                 // '((2 + x) * 3)'

Adding a node type touches every fold (the compiler tells you where); adding an operation is one new function. That is the trade-off, and it is the exact dual of the class-based design — see the expression-problem note under Visitor.

Sightings: the TypeScript compiler’s own AST, Babel, PostCSS, JSONLogic, MongoDB query documents, Prisma’s filter objects, jq.

5.4 Iterator

Intent: traverse a collection without exposing its representation.

This pattern is built into the language. Symbol.iterator, generators, and (ES2026) iterator helpers. Writing an iterator class in TypeScript is almost always wrong.

class Playlist implements Iterable<string> {
  #tracks: string[] = [];
  add(t: string) { this.#tracks.push(t); return this; }

  *[Symbol.iterator]() { yield* this.#tracks; }        // for...of, spread, destructuring all work

  *shuffled(seed = 1) {                                // a second, differently-ordered traversal
    const a = [...this.#tracks]; let s = seed;
    for (let i = a.length - 1; i > 0; i--) {
      s = (s * 1103515245 + 12345) % 2147483648;
      const j = s % (i + 1);
      [a[i], a[j]] = [a[j]!, a[i]!];
    }
    yield* a;
  }
}

const p = new Playlist().add('a').add('b').add('c');
[...p];                                                        // ['a','b','c']
[...p].values().map(s => s.toUpperCase()).take(2).toArray();    // ['A','B'] — lazy, no intermediates

Note the generator method form: *[Symbol.iterator]() returns a fresh iterator each call, so the collection is multi-pass. Returning this from [Symbol.iterator]() would make it single-pass — the classic bug, and a good follow-up question.

Sightings: Map/Set/Array iterators, NodeList, Node streams as async iterables, database cursors, paginated API clients.

Q: Iterable vs Iterator?

A: An iterable has [Symbol.iterator](); an iterator has next(). Generators are both, which is why you can only spread a generator once.

Q: Why do iterator helpers matter?

A: They make lazy pipelines first-class: map/filter/take on an iterator never materializes an intermediate array, so you can operate on infinite sequences. array.map().filter().slice() allocates two full arrays.

5.5 Mediator

Intent: an object that encapsulates how a set of objects interact, so they do not reference each other.

Earns its keep when n components would otherwise need n^2 references — form field interdependencies, a chat room, an air-traffic controller. Over-engineering when it becomes a God object that knows every rule in the system, which is the standard failure mode.

   without                            with
  A ── B                        A     B     C
  │ ╳  │                         \    |    /
  C ── D                          ──> M <──
  (6 edges)                       (n edges, all through the mediator)
interface Mediator { notify(sender: string, event: string): void }

class AuthMediator implements Mediator {
  constructor(private ui: { setLoading(b: boolean): void; showError(m: string): void }) {}
  notify(sender: string, event: string) {
    if (event === 'submit') this.ui.setLoading(true);
    if (event === 'failure') { this.ui.setLoading(false); this.ui.showError('bad credentials'); }
  }
}

Mediator vs Observer: a mediator is directive — it knows the participants and encodes the rules between them. An observer is broadcast — the publisher knows nothing about who is listening. Mediator centralizes coupling; Observer removes it. That distinction is the interview question.

Sightings: Redux store (the reducer is the mediator), XState machines, form libraries coordinating field validation, and — arguably — any well-designed service layer.

5.6 Memento

Intent: capture an object’s internal state so it can be restored later, without exposing that state.

Earns its keep for undo, checkpoints, and transactional rollback. Over-engineering when the state is already a plain immutable value — then a copy is the memento.

class TextDoc {
  #content = '';
  get content() { return this.#content; }
  type(s: string) { this.#content += s; }

  save(): Readonly<{ content: string }> { return Object.freeze({ content: this.#content }); }
  restore(m: Readonly<{ content: string }>) { this.#content = m.content; }
}

const d = new TextDoc(); d.type('v1');
const snap = d.save();
d.type('-v2');        // 'v1-v2'
d.restore(snap);      // 'v1'

The design rule: the originator owns serialization (only it knows what its private state means); the caretaker just stores opaque mementos and never inspects them. In TypeScript the “opaque” part can be enforced with a branded type or a #private field on the memento.

Memento vs Command: Memento stores state; Command stores the operation. Undo via memento is snapshot-and-restore (simple, memory-heavy); undo via command is inverse-operation (compact, needs every operation to be invertible). Real editors use both — commands with periodic snapshots.

Sightings: structuredClone-based snapshots, Redux DevTools time travel, database savepoints, git commits, immer patches.

5.7 Observer

Intent: notify dependents automatically when an object changes.

Earns its keep everywhere; this is the one GoF behavioural pattern that has become more relevant. Over-engineering basically never, but the failure modes are real: leaked subscriptions, unpredictable notification order, and cascading updates.

type Listener<T> = (value: T) => void;

class Observable<T> {
  #listeners = new Set<Listener<T>>();
  subscribe(fn: Listener<T>): () => void {
    this.#listeners.add(fn);
    return () => this.#listeners.delete(fn);        // return the UNSUBSCRIBE — the key API decision
  }
  emit(v: T) {
    for (const fn of [...this.#listeners]) fn(v);   // snapshot: a listener may unsubscribe mid-emit
  }
}

// The modern reactive variant: current value + dedupe + immediate delivery on subscribe.
function signal<T>(initial: T) {
  let value = initial;
  const subs = new Set<Listener<T>>();
  return {
    get: () => value,
    set: (v: T) => { if (v !== value) { value = v; for (const s of [...subs]) s(v); } },
    subscribe: (fn: Listener<T>) => { subs.add(fn); fn(value); return () => subs.delete(fn); },
  };
}

Three design decisions that matter more than the pattern itself:

  1. subscribe returns an unsubscribe function. Anything else (an off(fn) that requires keeping the original reference) leaks.
  2. Copy the listener set before iterating. A handler that subscribes or unsubscribes during emit otherwise corrupts the iteration.
  3. Decide sync vs async delivery. Synchronous is predictable and can re-enter; asynchronous (microtask) batches and avoids re-entrancy but reorders relative to the rest of your code.

Sightings: EventTarget/addEventListener, Node’s EventEmitter, RxJS Observable, Vue’s reactivity, Solid/Angular/Preact signals, MutationObserver, IntersectionObserver, React’s useSyncExternalStore.

Q: How do you avoid memory leaks with observers?

A: Always return and call the unsubscribe (useEffect cleanup, takeUntil, AbortSignal). For caches of observers keyed by object, a WeakMap/WeakRef. Node warns at 11 listeners on one event precisely because leaks look like this.

Q: Push or pull?

A: Push sends the value with the notification (cheap for subscribers, the publisher decides granularity). Pull sends only “something changed” and the subscriber re-reads (fewer wasted payloads, enables batching — this is what React’s useSyncExternalStore and signals do).

5.8 State

Intent: let an object change its behaviour when its internal state changes.

Earns its keep for genuine state machines: orders, media players, connections, wizards, editors. Over-engineering when there are two states and one boolean.

The class-based GoF version (one class per state, this.state = new NextState()) is legitimate but heavy. In TypeScript the discriminated union plus a transition function is usually better, because it makes illegal states unrepresentable and gets exhaustiveness checking:

type OrderState =
  | { status: 'draft' }
  | { status: 'paid'; txId: string }
  | { status: 'shipped'; txId: string; tracking: string }
  | { status: 'cancelled'; reason: string };

type OrderEvent =
  | { type: 'PAY'; txId: string }
  | { type: 'SHIP'; tracking: string }
  | { type: 'CANCEL'; reason: string };

function transition(s: OrderState, e: OrderEvent): OrderState {
  switch (s.status) {
    case 'draft':
      return e.type === 'PAY' ? { status: 'paid', txId: e.txId }
           : e.type === 'CANCEL' ? { status: 'cancelled', reason: e.reason } : s;
    case 'paid':
      return e.type === 'SHIP' ? { status: 'shipped', txId: s.txId, tracking: e.tracking }
           : e.type === 'CANCEL' ? { status: 'cancelled', reason: e.reason } : s;
    case 'shipped':   return s;                    // terminal
    case 'cancelled': return s;                    // terminal
    default: { const _x: never = s; return _x; }
  }
}

Notice what the type system buys you: a draft order has no txId at all, so order.txId is a compile error until you have narrowed to paid or shipped. That is strictly better than a class hierarchy where every state class carries every field, and strictly better than { status: string; txId?: string } where 8 nonsense combinations are representable.

State vs Strategy: identical structure, opposite intent and lifetime. Strategy is chosen by the client and does not change itself; State is chosen by the object and transitions itself. If the object decides its own next behaviour, it is State.

Sightings: XState, Redux reducers, TCP connection states, ReadableStream states, React Router transitions, document.readyState.

5.9 Strategy

Intent: encapsulate interchangeable algorithms.

In TypeScript this pattern is a function parameter. The interface-plus-three-classes version is ceremony unless the strategies need their own state or lifecycle.

type PricingStrategy = (subtotal: number, qty: number) => number;

const strategies = {
  none: (s) => s,
  bulk: (s, q) => (q >= 10 ? s * 0.9 : s),
  loyalty: (s) => s * 0.95,
} satisfies Record<string, PricingStrategy>;

function checkout(subtotal: number, qty: number, strategy: PricingStrategy = strategies.none) {
  return Math.round(strategy(subtotal, qty) * 100) / 100;
}

checkout(100, 12, strategies.bulk);      // 90
checkout(100, 1, strategies.loyalty);    // 95

satisfies is the detail worth pointing at: it checks that every entry has the right signature while preserving the literal keys, so keyof typeof strategies is 'none' | 'bulk' | 'loyalty' and you get autocomplete plus exhaustiveness. An annotation (const strategies: Record<string, PricingStrategy>) would throw that away. See TypeScript §3.4.

When the class version is still right: the strategy has configuration (a RetryStrategy with its own backoff parameters), needs to be serialized/named, or is loaded as a plugin.

Sightings: Array.prototype.sort comparators, passport.js auth strategies, webpack loaders, any { compare }/{ hash }/{ serialize } option object.

5.10 Template Method

Intent: define the skeleton of an algorithm, letting subclasses override specific steps.

Earns its keep when the skeleton is genuinely fixed and there are several required hooks with sensible defaults. Over-engineering — and it is over-engineered often — when a function taking callbacks would do, because inheritance couples the subclass to the base class’s private structure.

abstract class Report {
  render(): string { return [this.header(), this.body(), this.footer()].join('\n'); }
  protected header() { return '=== report ==='; }   // default hook
  protected abstract body(): string;                 // required hook
  protected footer() { return '--- end ---'; }
}
class SalesReport extends Report { protected body() { return 'sales: 42'; } }

// The function equivalent: no inheritance, no protected members, trivially testable.
function makeReport(
  body: () => string,
  header = () => '=== report ===',
  footer = () => '--- end ---',
) {
  return () => [header(), body(), footer()].join('\n');
}

Both produce the same output. The function version composes (you can partially apply it), does not require a class per variant, and cannot be broken by a subclass calling super incorrectly. Prefer it unless you need the subclass to share protected state.

Sightings: React class-component lifecycle, Array.prototype.sort (the skeleton is the sort; the hook is the comparator), test framework setUp/tearDown, JSON.stringify’s toJSON hook, abstract base classes in most frameworks.

Q: Template Method vs Strategy?

A: Template Method uses inheritance and fixes the algorithm at compile time; Strategy uses composition and swaps at runtime. Template Method can share protected state with its hooks; Strategy cannot.

5.11 Visitor

Intent: add operations to an object structure without modifying it.

Earns its keep when the structure is stable and you keep adding operations — compilers, linters, serializers, and any AST work. Over-engineering when the structure changes often, because every new node type touches every visitor.

The GoF double-dispatch (node.accept(visitor) calling visitor.visitX(this)) exists to work around languages without pattern matching. TypeScript’s discriminated unions make it a fold:

type Node =
  | { kind: 'file'; name: string; size: number }
  | { kind: 'dir'; name: string; children: Node[] };

interface Visitor<R> {
  file(n: Extract<Node, { kind: 'file' }>): R;
  dir(n: Extract<Node, { kind: 'dir' }>, childResults: R[]): R;
}

function visit<R>(n: Node, v: Visitor<R>): R {
  return n.kind === 'file' ? v.file(n) : v.dir(n, n.children.map(c => visit(c, v)));
}

const totalSize = visit<number>(tree, { file: f => f.size, dir: (_, cs) => cs.reduce((a, b) => a + b, 0) });
const names     = visit<string[]>(tree, { file: f => [f.name], dir: (d, cs) => [d.name, ...cs.flat()] });

visit owns the traversal once; each visitor only says what to do per node. Passing childResults in rather than letting the visitor recurse is what makes it a fold and keeps every visitor free of traversal logic.

The expression problem, which is the real content of this pattern: you can make it easy to add new types (class-based polymorphism: one new class, every method in it) or easy to add new operations (visitor/fold: one new function, every case in it), but not both without extra machinery. Say that out loud and you have answered the question behind the question.

Sightings: the TypeScript compiler’s ts.forEachChild, Babel plugins, ESLint rules, estraverse, Rust’s syn, Python’s ast.NodeVisitor.

Behavioural test run

  ok  Chain of Responsibility as composed middleware (the Express/Koa shape)
  ok  Command: closures over the receiver give undo without a class per command
  ok  Interpreter: discriminated-union AST, exhaustive switch, one fold per operation
  ok  Iterator: Symbol.iterator + generators + ES2026 iterator helpers (lazy, no intermediate arrays)
  ok  Mediator: components talk to the hub, not to each other (n edges instead of n^2)
  ok  Memento: an opaque frozen snapshot; the originator owns serialization, the caretaker just stores it
  ok  Observer: unsubscribe-returning subscribe, snapshot the listener set, plus a deduping signal
  ok  State as a discriminated union: illegal states unrepresentable, exhaustiveness compiler-checked
  ok  Strategy is a function parameter; `satisfies` keeps the literal keys AND checks the signatures
  ok  Template Method: abstract hooks, and the higher-order-function equivalent with default params
  ok  Visitor: one traversal, many operations — a fold over the union, no double dispatch needed

ALL BEHAVIOURAL PATTERN ASSERTIONS PASSED (11 patterns)

Type-design note. Four judgement calls that belong beside this chapter’s patterns — pushing nullability to the perimeter, distinct types for special values, the loose-input/strict-internal split, and prefer imprecise types to inaccurate ones — are in 21 §4.7 and 21 §5.7.

6. Patterns idiomatic to TypeScript and JavaScript

These are the ones that actually come up in a TypeScript design discussion. Several replace a GoF pattern outright.

6.1 The module as the unit of encapsulation

An ES module is a singleton with private state, evaluated once and cached. That covers most of what people reach for Singleton and Facade for:

// config.ts — module-level state IS the singleton, with no class and no getInstance()
let cache: Config | null = null;
export function getConfig(): Config {
  cache ??= Object.freeze(loadFromEnv());
  return cache;
}
export function __resetForTests() { cache = null; }   // the escape hatch tests need

The __resetForTests export is the honest admission that module singletons are global mutable state. The alternative — and the better answer when asked — is to make the dependency explicit (section 6.5) so no reset is needed.

Closures give the same encapsulation without a module:

function createCounter(start = 0) {
  let n = start;                      // genuinely private: no `#`, no WeakMap, not reachable
  return { inc: () => ++n, get value() { return n; } };
}

6.2 Function composition instead of class hierarchies

const pipe = <T>(...fns: Array<(x: T) => T>) => (x: T) => fns.reduce((v, f) => f(v), x);

const slugify = pipe<string>(
  s => s.trim(),
  s => s.toLowerCase(),
  s => s.replace(/[^a-z0-9]+/g, '-'),
  s => s.replace(/^-|-$/g, ''),
);

Typed variadic pipe (where each function’s input must match the previous output) needs variadic tuple types — see TypeScript §9.1. Currying, partial application, memoize, once, debounce and throttle are all in JS core §4; they are patterns even though no book calls them that.

6.3 Mixins

The class-factory mixin is TypeScript’s answer to multiple inheritance:

type Ctor<T = {}> = new (...args: any[]) => T;

const Serializable = <T extends Ctor>(Base: T) =>
  class extends Base { toJSON() { return { ...this }; } };
const Timestamped = <T extends Ctor>(Base: T) =>
  class extends Base { createdAt = new Date(0); };

class User extends Serializable(Timestamped(class {})) {
  constructor(public name: string) { super(); }
}

Each mixin adds a real link to the prototype chain, so instanceof on the mixin does not work (use Symbol.hasInstance or a brand if you need it). Prefer composition — a serialize(user) function — unless you specifically need the methods on the instance.

6.4 Discriminated unions as the primary modelling tool

This deserves its own entry because it replaces so many patterns: State (5.8), Interpreter (5.3), Null Object, and most uses of inheritance for variant data.

type Fetch<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

Four states, each carrying exactly the fields it needs. Compare with { loading?: boolean; data?: T; error?: Error }, which can represent 8 combinations, at least 5 of which are nonsense. Make illegal states unrepresentable is the single most valuable design principle the type system gives you, and it is worth saying in exactly those words.

6.5 Dependency injection, three ways, and when a container earns its keep

interface Clock { now(): number }
interface Logger { log(m: string): void }

// 1. Constructor injection — the default. Explicit, testable, no framework.
class Service {
  constructor(private clock: Clock, private logger: Logger) {}
  handle() { this.logger.log(`t=${this.clock.now()}`); return this.clock.now(); }
}

// 2. A typed factory/registry — a "container" that is 4 lines and needs no decorators.
type Registry = { clock: Clock; logger: Logger };
function createContainer(overrides: Partial<Registry> = {}): Registry {
  return { clock: { now: () => Date.now() }, logger: { log: () => {} }, ...overrides };
}

// 3. Just a parameter. For a single dependency this is the whole pattern.
const handleWith = (clock: Clock) => () => clock.now();

A real DI container (InversifyJS, NestJS, tsyringe) buys you automatic transitive resolution, lifetime scopes (singleton / request / transient), and interception. It costs you decorators, reflect-metadata, runtime-only wiring errors, and a learning curve. The honest answer: a container earns its keep in a large application with request-scoped dependencies and many layers; below that, the 4-line createContainer above gives you 90% of the value with none of the magic.

Service locator (container.get('logger') from inside the class) looks similar and is worse: dependencies become invisible in the signature and failures move from compile time to runtime.

6.6 Result / Either for expected failures

type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
const Ok  = <T>(value: T): Result<T, never> => ({ ok: true, value });
const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });

const mapR    = <T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> => (r.ok ? Ok(f(r.value)) : r);
const andThen = <T, U, E>(r: Result<T, E>, f: (t: T) => Result<U, E>): Result<U, E> => (r.ok ? f(r.value) : r);

type ParseError =
  | { code: 'NOT_A_NUMBER'; input: string }
  | { code: 'OUT_OF_RANGE'; value: number };

function parsePort(s: string): Result<number, ParseError> {
  const n = Number(s);
  if (!Number.isInteger(n)) return Err({ code: 'NOT_A_NUMBER', input: s });
  if (n < 1 || n > 65535)   return Err({ code: 'OUT_OF_RANGE', value: n });
  return Ok(n);
}

The argument for it: TypeScript has no throws clause, so a thrown error is invisible to the type system and impossible to handle exhaustively. Putting the error in the return type turns “did I handle every failure?” into a compiler question. The argument against: it is viral, it fights every library that throws, and try/catch is what the ecosystem does. Use it at boundaries (parsing, validation, IO) and let exceptions handle genuinely exceptional conditions. Cross-reference TypeScript §14.1.

6.7 Branded types and parse-at-the-boundary

declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

type UserId = Brand<string, 'UserId'>;
type Email  = Brand<string, 'Email'>;

const asUserId = (s: string): UserId => s as UserId;          // one sanctioned assertion, in one place
const parseEmail = (s: string): Result<Email, 'INVALID_EMAIL'> =>
  /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s) ? Ok(s as Email) : Err('INVALID_EMAIL');

Zero runtime cost, and it makes “primitive obsession” a compile error: a function taking UserId cannot be passed a PostId, an unvalidated string, or an Email. Combine with parse, don’t validate — convert unknown input into a branded type once at the edge, and every function downstream can assume it is valid because the type says so.

6.8 Repository and Unit of Work

interface Repository<T, ID> {
  findById(id: ID): Promise<T | null>;
  findAll(spec?: Partial<T>): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: ID): Promise<void>;
}

interface UnitOfWork {
  users: Repository<User, UserId>;
  orders: Repository<Order, OrderId>;
  commit(): Promise<void>;
  rollback(): Promise<void>;
}

Repository hides the persistence mechanism behind a collection-like interface, which makes the domain testable with an in-memory implementation. Unit of Work groups several repository operations into one transaction. The honest caveats: a repository that just wraps an ORM adds a layer for nothing, and a generic Repository<T> tends to leak query concerns (pagination, projections, joins) until you either give up or reinvent a query language. Use it when you have a domain model worth protecting; skip it for CRUD.

6.9 Object pool

class Pool<T> {
  #free: T[] = [];
  constructor(private factory: () => T, private reset: (t: T) => void, private max = 4) {}
  acquire(): T { return this.#free.pop() ?? this.factory(); }
  release(t: T) { this.reset(t); if (this.#free.length < this.max) this.#free.push(t); }
}

Only worth it when construction is genuinely expensive: database connections, worker threads, large typed-array buffers, WebGL objects, parsers with big internal tables. For ordinary objects, V8’s generational scavenger collects short-lived garbage almost for free (JS core §7.1), so pooling makes things slower and adds use-after-release bugs. Resetting on release rather than on acquire is the detail: it means a leaked reference cannot read another consumer’s data.

6.10 Async patterns

The ones you should be able to write cold. Full implementations and the event-loop background are in JS core §6.8; the summary:

// Bounded concurrency: N workers pulling from a shared index. Order preserved by writing to out[i].
async function pMap<T, R>(items: readonly T[], fn: (t: T, i: number) => Promise<R>, limit = 4) {
  const out = new Array<R>(items.length);
  let next = 0;
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (next < items.length) { const i = next++; out[i] = await fn(items[i]!, i); }
  }));
  return out;
}

// Retry with full jitter.
async function retry<T>(fn: (attempt: number) => Promise<T>, attempts = 4, base = 100): Promise<T> {
  let last: unknown;
  for (let i = 0; i < attempts; i++) {
    try { return await fn(i); }
    catch (e) { last = e; if (i === attempts - 1) break; await sleep(Math.random() * base * 2 ** i); }
  }
  throw last;
}

// Circuit breaker: closed -> open (after N failures) -> half-open (after a cooldown) -> closed.
class CircuitBreaker {
  #failures = 0; #openedAt = 0; #state: 'closed' | 'open' | 'half-open' = 'closed';
  constructor(private threshold = 3, private cooldownMs = 50, private now = () => Date.now()) {}
  get state() {
    if (this.#state === 'open' && this.now() - this.#openedAt >= this.cooldownMs) this.#state = 'half-open';
    return this.#state;
  }
  async call<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') throw new Error('circuit open');
    try { const r = await fn(); this.#failures = 0; this.#state = 'closed'; return r; }
    catch (e) {
      if (++this.#failures >= this.threshold) { this.#state = 'open'; this.#openedAt = this.now(); }
      throw e;
    }
  }
}

The now = () => Date.now() constructor parameter is the design detail worth pointing out: injecting the clock is what makes a time-dependent component testable without sleeping. The test for this class advances a variable instead of waiting 50ms.

Note the pMap shape: limit workers each pulling the next index. That is simpler and more obviously correct than the recursive “refill” version, and it preserves output order because each worker writes to out[i] rather than pushing.

Debounce and throttle belong in this list too — see JS core §4.5.

6.11 Immutability with structural sharing

type Tree = { value: number; children: readonly Tree[] };

function setValue(t: Tree, path: readonly number[], v: number): Tree {
  if (path.length === 0) return { ...t, value: v };
  const [i, ...rest] = path as [number, ...number[]];
  return { ...t, children: t.children.map((c, idx) => (idx === i ? setValue(c, rest, v) : c)) };
}

const t2 = setValue(t, [0], 99);
t2.children[1] === t.children[1];   // true — the untouched subtree is REFERENCE-IDENTICAL

Verified. That reference identity is the whole point: it costs O(path length) instead of O(size), and it is what makes React’s shallow-compare re-render check, useMemo dependency arrays, undo stacks, and time-travel debugging work. readonly and Readonly<T> are compile-time only, so add Object.freeze or a library (immer, immutable.js) if you need runtime enforcement.

6.12 Null Object versus Option

// Null Object: a do-nothing implementation, so callers never branch.
const noopLogger: Logger = { log() {} };

// Option: make absence explicit and force the caller to handle it.
type Option<T> = { some: true; value: T } | { some: false };

Null Object removes a branch at the cost of hiding a real condition (a silently no-op logger is a debugging nightmare). Option adds a branch the compiler enforces. Prefer Null Object for genuinely optional behaviour (logging, metrics, analytics) and Option / T | undefined with strictNullChecks for missing data.


7. Anti-patterns and the questions about them

Anti-patternHow to recognize itThe refactor
God object / God componentone class or file with unrelated responsibilities, hundreds of lines, imported everywheresplit by responsibility (SRP); extract cohesive collaborators; in React, split hooks out of the component
Anemic domain modelclasses with only getters/setters, all logic in *Service classesmove invariant-preserving behaviour onto the entity; use branded/value types so invalid states cannot be constructed
Singleton as global mutable stategetInstance() plus mutable fields; tests that must run in orderinject the dependency; if you must keep the module singleton, export a reset for tests and freeze the value
Service locatorcontainer.get('x') inside a classconstructor injection, so dependencies appear in the signature and fail at compile time
Premature abstractionone implementation behind an interface, a factory that only ever makes one thing, AbstractBaseFactoryProviderinline it; add the abstraction at the second real implementation (rule of three)
Inheritance for code reuseextends where the subtype is not substitutable; deep hierarchies; super calls sprinkled throughcomposition, mixins, or plain functions. See the LSP demo below
Primitive obsessionstring ids, number money, positional (string, string, boolean) parametersbranded types, value objects, an options object
Callback pyramid / promise soupnesting, .then chains that pass tuples aroundasync/await, Promise.all, a concurrency pool
Leaky abstractiona Repository whose method names are SQL; a cache you must invalidate by hand from callersmove the leaking concern inside, or stop pretending and expose the real API
Stringly-typed codeif (kind === 'admin') with no union type; magic strings scattereda union of literals or an as const object; make the compiler check the spelling
Boolean parametersrender(true, false, true)an options object, or separate functions
Exceptions as control flowtry/catch around expected outcomes, empty catch {}a Result type, or an explicit predicate

The LSP violation, verified at runtime:

class Rectangle {
  constructor(public width: number, public height: number) {}
  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
  get area() { return this.width * this.height; }
}
class Square extends Rectangle {
  setWidth(w: number) { this.width = w; this.height = w; }    // breaks the base contract
  setHeight(h: number) { this.width = h; this.height = h; }
}
function stretch(r: Rectangle) { r.setWidth(5); r.setHeight(4); return r.area; }

stretch(new Rectangle(1, 1));   // 20
stretch(new Square(1, 1));      // 16  <- the subtype broke a caller that only knows the base type

Both compile; TypeScript’s structural typing cannot catch it because the signatures match and only the contract is violated. The fix is not a better type: it is recognizing that a square is not a behavioural subtype of a mutable rectangle. Make them separate immutable value types with a shared Shape interface exposing only area.


8. Decision table

I need to…PatternThe plainer alternative
one shared instanceSingletona module with module-level state, or inject it
create objects whose type is decided at runtimeFactory Method / Abstract Factorya Record<Kind, () => T> lookup, or a function with a switch
build an object with many optional partsBuilderan options object with defaults; satisfies to check it
copy an existing configured objectPrototypestructuredClone, or spread for shallow
make an incompatible interface fitAdaptera wrapper function
swap the implementation behind an abstractionBridgea constructor parameter typed as an interface
treat one thing and many things uniformlyCompositea recursive discriminated union
add behaviour to one objectDecoratora higher-order function, or Proxy
simplify a subsystem’s surfaceFacadea module that re-exports a curated API
share many identical immutable objectsFlyweightinterning via a Map, or memoize
intercept access to an objectProxyProxy, or explicit getters
pass a request down a configurable list of handlersChain of Responsibilitycomposed middleware functions
queue / log / undo operationsCommandclosures for do, plus an explicit inverse for undo
evaluate a small DSLInterpretera discriminated-union AST plus a fold
iterate without exposing internalsIteratorSymbol.iterator and generators
stop n components referencing each otherMediatora reducer, or a single coordinating hook/service
snapshot and restore stateMementoa frozen copy, or an immutable value
notify dependents of changeObserversubscribe returning an unsubscribe; a signal
change behaviour as state changesStatea discriminated union + transition(state, event)
swap an algorithmStrategya function parameter
fix an algorithm’s skeleton, vary the stepsTemplate Methoda function taking callbacks with defaults
add operations to a stable structureVisitora fold over a discriminated union
model expected failure(none in GoF)Result<T, E> at boundaries, exceptions for the unexpected
stop mixing up two strings(none in GoF)branded types + parse at the boundary
bound concurrency, retry, or fail fast(none in GoF)pMap, retry with jitter, circuit breaker
share dependencies without globalsDependency Injectionconstructor parameters; a 4-line typed container if you must

9. Interview questions

Q: Which GoF patterns are obsolete in modern TypeScript, and why?

A: Not obsolete so much as absorbed into the language. Strategy is a function parameter. Command is a closure (unless you need undo). Iterator is Symbol.iterator plus generators. Singleton is a module. Template Method is a function with default callbacks. Prototype is structuredClone or spread. Factory Method is often a Record<Kind, () => T>. Interpreter and Visitor become discriminated unions and folds. What remains genuinely useful as patterns: Observer, State (as a union), Composite, Adapter, Facade, Proxy, Decorator, Chain of Responsibility, and Builder for genuinely complex construction.

Q: Adapter, Facade, Proxy and Decorator all wrap something. Distinguish them.

A: By intent, not structure. Adapter changes an interface so two incompatible things fit. Facade simplifies a complex subsystem behind a smaller surface. Proxy keeps the same interface but controls access (lazy loading, caching, permissions, remoting). Decorator keeps the same interface but adds behaviour, and is designed to stack. One sentence each: adapt = convert, facade = simplify, proxy = control, decorate = enhance.

Q: Strategy vs Template Method?

A: Strategy composes and swaps at runtime; Template Method inherits and fixes the variation at compile time. Strategy’s algorithm object cannot see the host’s internals; Template Method’s hooks can see protected state. Prefer Strategy — composition over inheritance.

Q: State vs Strategy?

A: Identical structure. Strategy is chosen by the client and never changes itself; State is chosen by the object and transitions itself. If setState lives inside the behaviour, it is State.

Q: Mediator vs Observer?

A: Mediator is directive and knows its participants, so it centralizes the interaction rules. Observer is broadcast and the publisher knows nothing about subscribers. Mediator reduces coupling between peers at the cost of coupling to the hub; Observer removes coupling entirely at the cost of implicit control flow.

Q: How do you test a singleton?

A: You mostly do not — you refactor so the dependency is injected, and the singleton wiring happens once at the application entry point. If you must keep it: export a reset function, or make the module export a factory and have the singleton be a module-level call to it. Global mutable state makes tests order-dependent, which is the actual cost.

Q: Composition over inheritance — give a case where inheritance is still right.

A: When the subtype genuinely is a behavioural subtype and you want to inherit an invariant, not just code: extending Error so instanceof and stack traces work; extending a framework base class whose lifecycle you must participate in (extends Array, extends EventEmitter, a React ErrorBoundary); implementing an abstract class with several required hooks and shared protected state. The test is Liskov: can every caller of the base type accept the subtype without knowing?

Q: How would you implement dependency injection without a framework?

A: Constructor injection plus a composition root: one file that constructs the graph and passes dependencies down. If you need overrides for tests, a createContainer(overrides: Partial<Registry>) factory is four lines and fully type-checked. Containers add transitive resolution and lifetime scopes; below a few dozen services they are not worth the decorators and runtime wiring errors.

Q: What is the difference between DI and a service locator, and why does it matter?

A: DI passes dependencies in, so they appear in the signature and a missing one is a compile error. A service locator lets the class pull them from a global registry, so dependencies are invisible and failures happen at runtime, in production, on a code path nobody tested.

Q: When is SOLID wrong?

A: When it produces indirection nobody needs. One interface per class, a factory per constructor, and a DIP-inverted boundary for a dependency that will never change all add reading cost and no optionality. SOLID is a set of forces to balance, not rules to satisfy; the rule of three (abstract on the third real case) is a better default than “always abstract”.

Q: What does satisfies buy you in a strategy or config map?

A: It checks every entry against the constraint while keeping the literal key and value types, so you still get keyof typeof map autocomplete and exhaustive switches. An annotation would widen the type and throw that away.

Q: How do you make illegal states unrepresentable?

A: A discriminated union where each variant carries exactly the fields valid in that state, instead of one object with optional fields. {status:'loading'} | {status:'ok';data:T} | {status:'err';error:E} has 3 states; {loading?:boolean; data?:T; error?:E} has 8, five of them nonsense.

Q: Why is Object.freeze not enough for immutability?

A: It is shallow and runtime-only. Nested objects stay mutable, Map/Set contents are unaffected, and readonly in TypeScript disappears at compile time. Use a recursive freeze, or a persistent-data library, or discipline plus DeepReadonly<T>.

Q: What is the expression problem?

A: Adding new types to a structure versus adding new operations over it. Class-based polymorphism makes the first easy and the second invasive; the Visitor/fold approach makes the second easy and the first invasive. Discriminated unions in TypeScript put you on the second side, which is usually right for compilers and wrong for plugin systems.

Q: When is an object pool a good idea in JavaScript?

A: Rarely. V8’s scavenger collects short-lived objects almost for free, so pooling usually costs performance and adds use-after-release bugs. It pays for genuinely expensive resources: DB connections, worker threads, large ArrayBuffers, WebGL/canvas objects, parser instances with big tables.

Q: How do you make a time-dependent component testable?

A: Inject the clock: constructor(private now = () => Date.now()). The circuit-breaker test in this file advances a variable instead of sleeping, which is why it runs in microseconds.

Q: Observer: how do you prevent leaks?

A: subscribe returns an unsubscribe function and callers must call it (React useEffect cleanup, RxJS takeUntil, an AbortSignal). For registries keyed by object, a WeakMap. Node’s “11 listeners added” warning exists because this leak is so common.

Q: Is the module pattern a singleton?

A: Effectively yes — an ES module is evaluated once and its bindings are cached, so module-level state is process-global. The dual-package hazard (a CJS and an ESM copy loaded together) is the exception that proves it is not a guarantee.

Q: How do you decide whether to introduce a pattern at all?

A: Name the change you expect. If a new payment provider is coming, Strategy or Factory earns its keep. If nothing is going to change, the pattern is speculative generality — cost now for optionality you will not use. “What varies?” is the whole question, and the answer should come from the roadmap, not from the pattern catalogue.


Next: the Python mirror of this file, Design patterns in Python.

Verify it yourself

dp2/beh.ts

import assert from 'node:assert/strict';
const out: string[] = []; const ok = (m: string) => out.push('  ok  ' + m);

/* ---- Chain of Responsibility: as a class chain, and as composed middleware ---- */
type Handler<T> = (req: T, next: () => string) => string;
function chain<T>(...hs: Handler<T>[]): (req: T) => string {
  return (req) => {
    const dispatch = (i: number): string => (i === hs.length ? 'unhandled' : hs[i]!(req, () => dispatch(i + 1)));
    return dispatch(0);
  };
}
{
  const auth: Handler<{ user?: string; amount: number }> = (r, next) => (r.user ? next() : 'reject: anonymous');
  const limit: Handler<{ user?: string; amount: number }> = (r, next) => (r.amount > 1000 ? 'reject: over limit' : next());
  const approve: Handler<{ user?: string; amount: number }> = () => 'approved';
  const pipeline = chain(auth, limit, approve);
  assert.equal(pipeline({ user: 'ana', amount: 50 }), 'approved');
  assert.equal(pipeline({ amount: 50 }), 'reject: anonymous');
  assert.equal(pipeline({ user: 'ana', amount: 5000 }), 'reject: over limit');
  ok('Chain of Responsibility as composed middleware (the Express/Koa shape)');
}

/* ---- Command with undo ---- */
interface Command { execute(): void; undo(): void; readonly label: string }
class Editor {
  text = '';
  #history: Command[] = [];
  run(c: Command) { c.execute(); this.#history.push(c); }
  undo() { this.#history.pop()?.undo(); }
}
const insertCmd = (ed: Editor, s: string): Command => ({
  label: `insert ${s}`,
  execute() { ed.text += s; },
  undo() { ed.text = ed.text.slice(0, -s.length); },
});
{
  const ed = new Editor();
  ed.run(insertCmd(ed, 'hello')); ed.run(insertCmd(ed, ' world'));
  assert.equal(ed.text, 'hello world');
  ed.undo(); assert.equal(ed.text, 'hello');
  ok('Command: closures over the receiver give undo without a class per command');
}

/* ---- Interpreter: a discriminated-union AST + a fold ---- */
type Expr =
  | { kind: 'num'; value: number }
  | { kind: 'var'; name: string }
  | { kind: 'add'; left: Expr; right: Expr }
  | { kind: 'mul'; left: Expr; right: Expr };
const evaluate = (e: Expr, env: Record<string, number>): number => {
  switch (e.kind) {
    case 'num': return e.value;
    case 'var': return env[e.name] ?? 0;
    case 'add': return evaluate(e.left, env) + evaluate(e.right, env);
    case 'mul': return evaluate(e.left, env) * evaluate(e.right, env);
    default: { const _e: never = e; return _e; }
  }
};
const show = (e: Expr): string => {
  switch (e.kind) {
    case 'num': return String(e.value);
    case 'var': return e.name;
    case 'add': return `(${show(e.left)} + ${show(e.right)})`;
    case 'mul': return `(${show(e.left)} * ${show(e.right)})`;
  }
};
{
  const ast: Expr = { kind: 'mul', left: { kind: 'add', left: { kind: 'num', value: 2 }, right: { kind: 'var', name: 'x' } }, right: { kind: 'num', value: 3 } };
  assert.equal(evaluate(ast, { x: 4 }), 18);
  assert.equal(show(ast), '((2 + x) * 3)');
  ok('Interpreter: discriminated-union AST, exhaustive switch, one fold per operation');
}

/* ---- Iterator: Symbol.iterator + lazy generator pipeline ---- */
class Playlist implements Iterable<string> {
  #tracks: string[] = [];
  add(t: string) { this.#tracks.push(t); return this; }
  *[Symbol.iterator]() { yield* this.#tracks; }
  *shuffled(seed = 1) {                       // deterministic LCG so the test is reproducible
    const a = [...this.#tracks]; let s = seed;
    for (let i = a.length - 1; i > 0; i--) { s = (s * 1103515245 + 12345) % 2147483648; const j = s % (i + 1); [a[i], a[j]] = [a[j]!, a[i]!]; }
    yield* a;
  }
}
{
  const p = new Playlist().add('a').add('b').add('c');
  assert.deepEqual([...p], ['a', 'b', 'c']);
  assert.equal([...p.shuffled()].length, 3);
  assert.deepEqual([...p].values().map(s => s.toUpperCase()).take(2).toArray(), ['A', 'B']);
  ok('Iterator: Symbol.iterator + generators + ES2026 iterator helpers (lazy, no intermediate arrays)');
}

/* ---- Mediator ---- */
interface Mediator { notify(sender: string, event: string): void }
class AuthMediator implements Mediator {
  log: string[] = [];
  constructor(private ui: { setLoading(b: boolean): void; showError(m: string): void }) {}
  notify(sender: string, event: string) {
    this.log.push(`${sender}:${event}`);
    if (event === 'submit') this.ui.setLoading(true);
    if (event === 'failure') { this.ui.setLoading(false); this.ui.showError('bad credentials'); }
  }
}
{
  const state = { loading: false, error: '' };
  const m = new AuthMediator({ setLoading: b => { state.loading = b; }, showError: e => { state.error = e; } });
  m.notify('form', 'submit'); assert.equal(state.loading, true);
  m.notify('api', 'failure'); assert.deepEqual([state.loading, state.error], [false, 'bad credentials']);
  ok('Mediator: components talk to the hub, not to each other (n edges instead of n^2)');
}

/* ---- Memento ---- */
class TextDoc {
  #content = '';
  get content() { return this.#content; }
  type(s: string) { this.#content += s; }
  save(): Readonly<{ content: string }> { return Object.freeze({ content: this.#content }); }
  restore(m: Readonly<{ content: string }>) { this.#content = m.content; }
}
{
  const d = new TextDoc(); d.type('v1');
  const snap = d.save();
  d.type('-v2'); assert.equal(d.content, 'v1-v2');
  d.restore(snap); assert.equal(d.content, 'v1');
  ok('Memento: an opaque frozen snapshot; the originator owns serialization, the caretaker just stores it');
}

/* ---- Observer: EventTarget-shaped, plus a signal ---- */
type Listener<T> = (value: T) => void;
class Observable<T> {
  #listeners = new Set<Listener<T>>();
  subscribe(fn: Listener<T>): () => void { this.#listeners.add(fn); return () => this.#listeners.delete(fn); }
  emit(v: T) { for (const fn of [...this.#listeners]) fn(v); }   // copy: a listener may unsubscribe
}
function signal<T>(initial: T) {
  let value = initial;
  const subs = new Set<Listener<T>>();
  return {
    get: () => value,
    set: (v: T) => { if (v !== value) { value = v; for (const s of [...subs]) s(v); } },
    subscribe: (fn: Listener<T>) => { subs.add(fn); fn(value); return () => subs.delete(fn); },
  };
}
{
  const obs = new Observable<number>();
  const seen: number[] = [];
  const off = obs.subscribe(v => seen.push(v));
  obs.emit(1); obs.emit(2); off(); obs.emit(3);
  assert.deepEqual(seen, [1, 2]);
  const s = signal(0); const got: number[] = [];
  s.subscribe(v => got.push(v));            // fires immediately with the current value
  s.set(1); s.set(1); s.set(2);              // deduped
  assert.deepEqual(got, [0, 1, 2]);
  ok('Observer: unsubscribe-returning subscribe, snapshot the listener set, plus a deduping signal');
}

/* ---- State: class-based vs discriminated union ---- */
type OrderState =
  | { status: 'draft' }
  | { status: 'paid'; txId: string }
  | { status: 'shipped'; txId: string; tracking: string }
  | { status: 'cancelled'; reason: string };
type OrderEvent =
  | { type: 'PAY'; txId: string }
  | { type: 'SHIP'; tracking: string }
  | { type: 'CANCEL'; reason: string };
function transition(s: OrderState, e: OrderEvent): OrderState {
  switch (s.status) {
    case 'draft':   return e.type === 'PAY' ? { status: 'paid', txId: e.txId } : e.type === 'CANCEL' ? { status: 'cancelled', reason: e.reason } : s;
    case 'paid':    return e.type === 'SHIP' ? { status: 'shipped', txId: s.txId, tracking: e.tracking } : e.type === 'CANCEL' ? { status: 'cancelled', reason: e.reason } : s;
    case 'shipped': return s;                       // terminal
    case 'cancelled': return s;
    default: { const _x: never = s; return _x; }
  }
}
{
  let st: OrderState = { status: 'draft' };
  st = transition(st, { type: 'PAY', txId: 't1' });
  assert.equal(st.status, 'paid');
  st = transition(st, { type: 'SHIP', tracking: 'Z9' });
  assert.deepEqual(st, { status: 'shipped', txId: 't1', tracking: 'Z9' });
  st = transition(st, { type: 'CANCEL', reason: 'late' });
  assert.equal(st.status, 'shipped');               // ignored: shipped is terminal
  ok('State as a discriminated union: illegal states unrepresentable, exhaustiveness compiler-checked');
}

/* ---- Strategy: just a function ---- */
type PricingStrategy = (subtotal: number, qty: number) => number;
const strategies = {
  none: (s) => s,
  bulk: (s, q) => (q >= 10 ? s * 0.9 : s),
  loyalty: (s) => s * 0.95,
} satisfies Record<string, PricingStrategy>;
function checkout(subtotal: number, qty: number, strategy: PricingStrategy = strategies.none) {
  return Math.round(strategy(subtotal, qty) * 100) / 100;
}
{
  assert.equal(checkout(100, 12, strategies.bulk), 90);
  assert.equal(checkout(100, 1, strategies.loyalty), 95);
  assert.equal(checkout(100, 1), 100);
  ok('Strategy is a function parameter; `satisfies` keeps the literal keys AND checks the signatures');
}

/* ---- Template Method: class hook vs higher-order function ---- */
abstract class Report {
  render(): string { return [this.header(), this.body(), this.footer()].join('\n'); }
  protected header() { return '=== report ==='; }         // default hook
  protected abstract body(): string;                       // required hook
  protected footer() { return '--- end ---'; }
}
class SalesReport extends Report { protected body() { return 'sales: 42'; } }
function makeReport(body: () => string, header = () => '=== report ===', footer = () => '--- end ---') {
  return () => [header(), body(), footer()].join('\n');    // the function version: no inheritance
}
{
  assert.equal(new SalesReport().render(), '=== report ===\nsales: 42\n--- end ---');
  assert.equal(makeReport(() => 'sales: 42')(), '=== report ===\nsales: 42\n--- end ---');
  ok('Template Method: abstract hooks, and the higher-order-function equivalent with default params');
}

/* ---- Visitor ---- */
type Node2 = { kind: 'file'; name: string; size: number } | { kind: 'dir'; name: string; children: Node2[] };
interface Visitor<R> { file(n: Extract<Node2, { kind: 'file' }>): R; dir(n: Extract<Node2, { kind: 'dir' }>, childResults: R[]): R }
function visit<R>(n: Node2, v: Visitor<R>): R {
  return n.kind === 'file' ? v.file(n) : v.dir(n, n.children.map(c => visit(c, v)));
}
{
  const tree: Node2 = { kind: 'dir', name: '/', children: [
    { kind: 'file', name: 'a.txt', size: 10 },
    { kind: 'dir', name: 'sub', children: [{ kind: 'file', name: 'b.txt', size: 32 }] },
  ]};
  const totalSize = visit<number>(tree, { file: f => f.size, dir: (_, cs) => cs.reduce((a, b) => a + b, 0) });
  const names = visit<string[]>(tree, { file: f => [f.name], dir: (d, cs) => [d.name, ...cs.flat()] });
  assert.equal(totalSize, 42);
  assert.deepEqual(names, ['/', 'a.txt', 'sub', 'b.txt']);
  ok('Visitor: one traversal, many operations — a fold over the union, no double dispatch needed');
}

console.log(out.join('\n'));
console.log(`\nALL BEHAVIOURAL PATTERN ASSERTIONS PASSED (${out.length} patterns)`);

dp2/idio.mts

import assert from 'node:assert/strict';
const out: string[] = []; const ok = (m: string) => out.push('  ok  ' + m);

/* ---- DI three ways ---- */
interface Clock { now(): number }
interface Logger { log(m: string): void }
class Service {                                   // 1. constructor injection
  constructor(private clock: Clock, private logger: Logger) {}
  handle() { this.logger.log(`t=${this.clock.now()}`); return this.clock.now(); }
}
type Registry = { clock: Clock; logger: Logger };  // 2. a typed container
function createContainer(overrides: Partial<Registry> = {}): Registry {
  return { clock: { now: () => Date.now() }, logger: { log: () => {} }, ...overrides };
}
const handleWith = (clock: Clock) => () => clock.now();   // 3. just a function parameter
{
  const lines: string[] = [];
  const c = createContainer({ clock: { now: () => 123 }, logger: { log: m => lines.push(m) } });
  assert.equal(new Service(c.clock, c.logger).handle(), 123);
  assert.deepEqual(lines, ['t=123']);
  assert.equal(handleWith({ now: () => 7 })(), 7);
  ok('DI three ways: constructor injection, a typed container with overrides, and a plain parameter');
}

/* ---- Result ---- */
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
const mapR = <T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> => (r.ok ? Ok(f(r.value)) : r);
const andThen = <T, U, E>(r: Result<T, E>, f: (t: T) => Result<U, E>): Result<U, E> => (r.ok ? f(r.value) : r);
type ParseError = { code: 'NOT_A_NUMBER'; input: string } | { code: 'OUT_OF_RANGE'; value: number };
function parsePort(s: string): Result<number, ParseError> {
  const n = Number(s);
  if (!Number.isInteger(n)) return Err({ code: 'NOT_A_NUMBER', input: s });
  if (n < 1 || n > 65535) return Err({ code: 'OUT_OF_RANGE', value: n });
  return Ok(n);
}
{
  const good = parsePort('8080');
  assert.ok(good.ok && good.value === 8080);
  const bad = parsePort('x');
  assert.ok(!bad.ok && bad.error.code === 'NOT_A_NUMBER');
  assert.deepEqual(mapR(parsePort('80'), n => n * 2), { ok: true, value: 160 });
  assert.equal(andThen(parsePort('x'), n => Ok(n)).ok, false);
  ok('Result: typed error unions the compiler forces you to handle, with map/andThen combinators');
}

/* ---- branded types ---- */
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
const asUserId = (s: string): UserId => s as UserId;
const parseEmail = (s: string): Result<Email, 'INVALID_EMAIL'> =>
  /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s) ? Ok(s as Email) : Err('INVALID_EMAIL');
{
  const id = asUserId('u_1');
  assert.equal(id, 'u_1');                        // zero runtime cost
  assert.ok(parseEmail('a@b.co').ok);
  assert.ok(!parseEmail('nope').ok);
  ok('Branded types: parse-at-the-boundary, one factory per brand, zero runtime cost');
}

/* ---- object pool ---- */
class Pool<T> {
  #free: T[] = [];
  #inUse = 0;
  constructor(private factory: () => T, private reset: (t: T) => void, private max = 4) {}
  acquire(): T {
    const t = this.#free.pop() ?? this.factory();
    this.#inUse++;
    return t;
  }
  release(t: T) { this.reset(t); this.#inUse--; if (this.#free.length < this.max) this.#free.push(t); }
  get stats() { return { free: this.#free.length, inUse: this.#inUse }; }
}
{
  let created = 0;
  const p = new Pool(() => ({ id: ++created, buf: [] as number[] }), o => { o.buf.length = 0; });
  const a = p.acquire(), b = p.acquire();
  p.release(a); p.release(b);
  const c = p.acquire();
  assert.equal(created, 2);                       // reused, not recreated
  assert.deepEqual(c.buf, []);                    // reset on release
  ok('Object pool: reuse expensive objects; reset on release, and cap the free list');
}

/* ---- bounded concurrency + retry + circuit breaker ---- */
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
async function pMap<T, R>(items: readonly T[], fn: (t: T, i: number) => Promise<R>, limit = 4): Promise<R[]> {
  const out = new Array<R>(items.length);
  let next = 0;
  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (next < items.length) { const i = next++; out[i] = await fn(items[i]!, i); }
  });
  await Promise.all(workers);
  return out;
}
async function retry<T>(fn: (attempt: number) => Promise<T>, attempts = 4, base = 1): Promise<T> {
  let last: unknown;
  for (let i = 0; i < attempts; i++) {
    try { return await fn(i); } catch (e) { last = e; if (i === attempts - 1) break; await sleep(Math.random() * base * 2 ** i); }
  }
  throw last;
}
class CircuitBreaker {
  #failures = 0;
  #openedAt = 0;
  #state: 'closed' | 'open' | 'half-open' = 'closed';
  constructor(private threshold = 3, private cooldownMs = 50, private now = () => Date.now()) {}
  get state() {
    if (this.#state === 'open' && this.now() - this.#openedAt >= this.cooldownMs) this.#state = 'half-open';
    return this.#state;
  }
  async call<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') throw new Error('circuit open');
    try {
      const r = await fn();
      this.#failures = 0; this.#state = 'closed';
      return r;
    } catch (e) {
      if (++this.#failures >= this.threshold) { this.#state = 'open'; this.#openedAt = this.now(); }
      throw e;
    }
  }
}
{
  const seen: number[] = [];
  const res = await pMap([1, 2, 3, 4, 5, 6], async n => { await sleep(5); seen.push(n); return n * n; }, 2);
  assert.deepEqual(res, [1, 4, 9, 16, 25, 36]);   // order preserved by index
  let calls = 0;
  const val = await retry(async () => { if (++calls < 3) throw new Error('flaky'); return 'ok'; });
  assert.deepEqual([val, calls], ['ok', 3]);
  let t = 0;
  const cb = new CircuitBreaker(2, 50, () => t);
  const boom = async () => { throw new Error('down'); };
  await assert.rejects(cb.call(boom)); await assert.rejects(cb.call(boom));
  assert.equal(cb.state, 'open');
  await assert.rejects(cb.call(async () => 'ignored'), /circuit open/);
  t = 100;
  assert.equal(cb.state, 'half-open');
  assert.equal(await cb.call(async () => 'recovered'), 'recovered');
  assert.equal(cb.state, 'closed');
  ok('Async patterns: bounded-concurrency pMap (order preserved), retry with jitter, circuit breaker (injectable clock)');
}

/* ---- LSP violation ---- */
class Rectangle { constructor(public width: number, public height: number) {} setWidth(w: number) { this.width = w; } setHeight(h: number) { this.height = h; } get area() { return this.width * this.height; } }
class Square extends Rectangle {
  setWidth(w: number) { this.width = w; this.height = w; }        // breaks the base contract
  setHeight(h: number) { this.width = h; this.height = h; }
}
function stretch(r: Rectangle) { r.setWidth(5); r.setHeight(4); return r.area; }
{
  assert.equal(stretch(new Rectangle(1, 1)), 20);
  assert.equal(stretch(new Square(1, 1)), 16);    // 16 !== 20: the subtype broke the contract
  ok('LSP violation demonstrated: Square extends Rectangle gives 16 where the contract says 20');
}

/* ---- immutability with structural sharing ---- */
type Tree = { value: number; children: readonly Tree[] };
function setValue(t: Tree, path: readonly number[], v: number): Tree {
  if (path.length === 0) return { ...t, value: v };
  const [i, ...rest] = path as [number, ...number[]];
  const children = t.children.map((c, idx) => (idx === i ? setValue(c, rest, v) : c));
  return { ...t, children };
}
{
  const leaf = { value: 3, children: [] as const };
  const t: Tree = { value: 1, children: [{ value: 2, children: [] }, leaf] };
  const t2 = setValue(t, [0], 99);
  assert.equal(t.children[0]!.value, 2);          // original untouched
  assert.equal(t2.children[0]!.value, 99);
  assert.ok(t2.children[1] === t.children[1]);    // STRUCTURAL SHARING: untouched subtree reused
  ok('Immutable update with structural sharing: untouched subtrees are reference-identical');
}

console.log(out.join('\n'));
console.log(`\nALL IDIOMATIC-TS ASSERTIONS PASSED (${out.length} groups)`);