Skip to content
Back to Homephamkhanhminhman.com / blog
TypeScript / Design• 19 Oct 2026• 10 min read

Eight design patterns from my 2022 practice folder, rewritten and run

In May 2022 I kept a folder of design pattern exercises: six PHP files, five TypeScript files with their compiled JavaScript, and one file that was not a pattern at all, a demo of Function.prototype.call. Reading it again, most of it ran. Several files, though, had copied the shape of a pattern without the reason for it.

This post rewrites all eight in TypeScript. Every snippet was type-checked with tsc --strict (TypeScript 5.9.3) and run with Node.js 22.21.1; the output blocks are copied from those runs. I did not run the old PHP, so what I say about it comes from reading the code. For each pattern: what the 2022 version did, the rewrite, its output, and when the pattern is not worth the extra code.

1. Singleton: one instance, but per what?

The 2022 version cached its instance in getInstance() correctly, but left the constructor public. Any new someClass() elsewhere would create a second instance and nothing would complain. The rewrite makes the constructor private:

class ConnectionPool {
  static #instance: ConnectionPool | undefined;
  static created = 0;

  private constructor() {
    ConnectionPool.created += 1;
  }

  static get(): ConnectionPool {
    ConnectionPool.#instance ??= new ConnectionPool();
    return ConnectionPool.#instance;
  }
}

const a = ConnectionPool.get();
const b = ConnectionPool.get();
console.log(a === b, "pools created:", ConnectionPool.created);
// new ConnectionPool();
// error TS2673: Constructor of class 'ConnectionPool' is private
// and only accessible within the class declaration.
true pools created: 1

private on a constructor is checked by the compiler only; plain JavaScript can still call it. In Node.js you rarely need the class anyway: a module is evaluated once and cached, so export const pool = new Pool() already hands every importer the same object. I checked the limit of that. Importing the module twice in the main thread returned the same object; a worker thread evaluated the module again and got its own copy:

pool module evaluated in thread 0
same object on second import: true
pool module evaluated in thread 1

“One instance” therefore means one per thread or process. Run the app under cluster, PM2 or several containers and each one opens its own pool, and the database sees the sum of all their connections.

When it is not worth it: a singleton is a global with a nicer name. Code that calls ConnectionPool.get() hides the dependency, and tests share its state. Passing the pool in as a parameter costs one argument and removes both problems.

2. Factory: let the compiler find the missing case

In 2022 the TypeScript factory took type: any and switched on 1 and 2. The compiled factory.js next to it was older than the .ts: its case 2 created a Nokia and never returned it. I ran that file with new Factory(2) and got TypeError: Cannot read properties of undefined (reading 'show'). The .ts had been fixed; nobody regenerated the .js.

interface Notifier {
  send(to: string, text: string): string;
}

class EmailNotifier implements Notifier {
  send(to: string, text: string) {
    return `email to ${to}: ${text}`;
  }
}

class SmsNotifier implements Notifier {
  send(to: string, text: string) {
    return `sms to ${to}: ${text}`;
  }
}

type Channel = "email" | "sms";

function createNotifier(channel: Channel): Notifier {
  switch (channel) {
    case "email":
      return new EmailNotifier();
    case "sms":
      return new SmsNotifier();
  }
}

console.log(createNotifier("sms").send("+84 900 000 000", "order shipped"));
sms to +84 900 000 000: order shipped

The typed input is the point. With a union instead of any and no default, deleting the "sms" branch while Channel still contains "sms" fails to compile:

error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

That is exactly the 2022 bug, caught before anything runs. When it is not worth it: with one implementation, new EmailNotifier() at the call site is clearer than a factory with one branch.

3. Abstract Factory: objects that must come in matching sets

The 2022 PHP made plastic and wooden furniture: each factory created a chair and a table of the same material. The idea was right. The producer's getFactory(), though, returned nothing for an unknown type, so a typo would surface later as a call on null, far from its cause. A backend version of the same idea is infrastructure that must match: in production a real mailer with real storage, in tests a captured mailer with in-memory storage.

interface Mailer {
  send(to: string): string;
}
interface FileStore {
  put(key: string): string;
}
interface InfraKit {
  mailer(): Mailer;
  files(): FileStore;
}

const productionKit: InfraKit = {
  mailer: () => ({ send: (to) => `SMTP -> ${to}` }),
  files: () => ({ put: (key) => `s3://bucket/${key}` }),
};

const testKit: InfraKit = {
  mailer: () => ({ send: (to) => `captured (not sent) -> ${to}` }),
  files: () => ({ put: (key) => `memory://${key}` }),
};

function sendInvoice(kit: InfraKit, customer: string) {
  const path = kit.files().put(`invoices/${customer}.pdf`);
  const mail = kit.mailer().send(customer);
  return `${path} | ${mail}`;
}

console.log(sendInvoice(productionKit, "an@example.com"));
console.log(sendInvoice(testKit, "an@example.com"));
s3://bucket/invoices/an@example.com.pdf | SMTP -> an@example.com
memory://invoices/an@example.com.pdf | captured (not sent) -> an@example.com

sendInvoice receives one kit, so it cannot pair a production mailer with test storage. The choice is made once, at startup or in the test setup. When it is not worth it: if each family has a single product, this is just a factory; if the families never change, passing the two objects in directly is simpler.

4. Builder: the director has to own the steps

The 2022 PHP built game heroes and had two problems. CentauBuilder's constructor calls Invoker() without new; reading the code, PHP would treat that as a call to an undefined function, so creating that builder would fail. And the director only stored a builder and returned getHero(), while the client called createHead() itself. A director that does not own the steps is just a getter.

class OrderQuery {
  #where: string[] = [];
  #params: unknown[] = [];
  #limit = 20;

  status(value: string) {
    this.#params.push(value);
    this.#where.push(`status = $${this.#params.length}`);
    return this;
  }

  createdAfter(date: string) {
    this.#params.push(date);
    this.#where.push(`created_at > $${this.#params.length}`);
    return this;
  }

  limit(n: number) {
    if (n < 1 || n > 100) throw new RangeError(`limit must be 1..100, got ${n}`);
    this.#limit = n;
    return this;
  }

  build() {
    const where = this.#where.length ? ` WHERE ${this.#where.join(" AND ")}` : "";
    return {
      sql: `SELECT * FROM orders${where} ORDER BY created_at DESC LIMIT ${this.#limit}`,
      params: this.#params,
    };
  }
}

// The "director": one place that knows the sequence for a recurring query.
function recentPaidOrders(since: string) {
  return new OrderQuery().status("paid").createdAfter(since).limit(50).build();
}

console.log(recentPaidOrders("2026-10-01"));

try {
  new OrderQuery().limit(500);
} catch (e) {
  console.log((e as Error).message);
}
{
  sql: 'SELECT * FROM orders WHERE status = $1 AND created_at > $2 ORDER BY created_at DESC LIMIT 50',
  params: [ 'paid', '2026-10-01' ]
}
limit must be 1..100, got 500

Two things justify a builder here. The placeholder numbers depend on the order of the calls, and the builder does that bookkeeping; values go into params, never into the SQL text. And invalid input fails at the step that caused it. recentPaidOrders is the director: the one place that knows the sequence for a query the code base runs often. When it is not worth it: if nothing depends on call order and there is nothing to validate between steps, an options object such as { status, createdAfter, limit } says the same in fewer lines.

5. Adapter: translate, not rename

The 2022 PaymentAdapter wrapped a Paypal class and turned send() into pay(). The amount was already passed to the Paypal constructor, so the adapter translated nothing; it renamed a method. An adapter earns its place when the two sides differ in shape, units or error handling. The vendor SDK below is made up for the example:

// The interface our code base uses everywhere.
interface PaymentGateway {
  charge(amountUsd: number, orderId: string): Promise<string>;
}

// A vendor SDK with its own shape (made up for this example).
class VendorPayClient {
  async createCharge(req: { amount_cents: number; currency: "USD"; reference: string }) {
    return { id: "ch_001", status: "succeeded", amount_cents: req.amount_cents, reference: req.reference };
  }
}

class VendorPayAdapter implements PaymentGateway {
  #client: VendorPayClient;

  constructor(client: VendorPayClient) {
    this.#client = client;
  }

  async charge(amountUsd: number, orderId: string) {
    const res = await this.#client.createCharge({
      amount_cents: Math.round(amountUsd * 100),
      currency: "USD",
      reference: orderId,
    });
    if (res.status !== "succeeded") throw new Error(`charge failed: ${res.status}`);
    return `${res.id} (${res.amount_cents} cents for ${res.reference})`;
  }
}

const gateway: PaymentGateway = new VendorPayAdapter(new VendorPayClient());
console.log(await gateway.charge(19.99, "order-42"));
ch_001 (1999 cents for order-42)

The unit conversion is where an adapter usually goes wrong: in JavaScript, 19.99 * 100 is 1998.9999999999998, so without Math.round the vendor would receive a fraction of a cent or, after truncation, one cent less. The rest of the code base depends only on PaymentGateway; changing vendor means writing another adapter instead of editing every call site. When it is not worth it: if the vendor's interface is already the one you would design, a wrapper adds a file and nothing else.

6. Decorator: wrap the same interface, mind the order

The 2022 pizza example had the right structure: every decorator implemented the same interface and wrapped another one. Each appended its topping directly to the inner string, so, reading the code, the final echo prints tomato pizzacheesepeper. In TypeScript the most common decorators wrap functions:

type Fetch = (url: string) => Promise<string>;

function withRetry(fn: Fetch, attempts: number): Fetch {
  return async (url) => {
    for (let i = 1; ; i++) {
      try {
        return await fn(url);
      } catch (e) {
        if (i >= attempts) throw e;
        console.log(`  retry ${i} after: ${(e as Error).message}`);
      }
    }
  };
}

function withTiming(fn: Fetch): Fetch {
  return async (url) => {
    const start = performance.now();
    try {
      return await fn(url);
    } finally {
      console.log(`  ${url} took ${(performance.now() - start).toFixed(0)} ms`);
    }
  };
}

let calls = 0;
const flaky: Fetch = async (url) => {
  calls += 1;
  if (calls < 3) throw new Error("ECONNRESET");
  return `200 OK from ${url}`;
};

const fetchOrder = withTiming(withRetry(flaky, 3));
console.log(await fetchOrder("/orders/42"));
  retry 1 after: ECONNRESET
  retry 2 after: ECONNRESET
  /orders/42 took 2 ms
200 OK from /orders/42

The milliseconds vary between runs. The order of wrapping does not, and it changes the meaning. As written, the timing covers all three attempts. Swap it to withRetry(withTiming(flaky), 3) and every attempt is timed on its own:

  /orders/42 took 0 ms
  retry 1 after: ECONNRESET
  /orders/42 took 0 ms
  retry 2 after: ECONNRESET
  /orders/42 took 0 ms
200 OK from /orders/42

When it is not worth it: five layers of wrappers make a stack trace hard to read. If every caller needs the behaviour, put it inside the client once.

7. Strategy: in TypeScript, often just a function

In 2022 the strategy was a Context class with a setter and two classes, Add and Sub, each holding one method. When a strategy has a single method and no state, a function type says the same thing:

type Discount = (subtotal: number) => number;

const discounts: Record<string, Discount> = {
  none: () => 0,
  percent10: (subtotal) => subtotal * 0.1,
  flat5Over50: (subtotal) => (subtotal >= 50 ? 5 : 0),
};

function checkout(subtotal: number, discount: Discount) {
  return subtotal - discount(subtotal);
}

for (const [name, discount] of Object.entries(discounts)) {
  console.log(name.padEnd(12), checkout(60, discount));
}
none         60
percent10    54
flat5Over50  55

Adding a discount means adding an entry; checkout does not change. A class is worth it when the strategy carries state or several related methods.

8. Observer: unsubscribe, and one listener can stop the rest

The 2022 folder had a useful before-and-after. In the old version, Store::changePrice() created User1 and User2 itself and called them, so the store knew every customer by name. The new version kept a list of observers, but each observer's constructor stored the return value of registerObserver(), a method that returns nothing, so the field was always null. The third file was the PHP Observer example from refactoring.guru, built on SplSubject; I am linking it rather than reproducing it.

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

class Topic<T> {
  #listeners = new Set<Listener<T>>();

  subscribe(listener: Listener<T>) {
    this.#listeners.add(listener);
    return () => this.#listeners.delete(listener); // unsubscribe
  }

  publish(event: T) {
    for (const listener of this.#listeners) listener(event);
  }
}

type PriceChanged = { sku: string; price: number };
const priceChanged = new Topic<PriceChanged>();

priceChanged.subscribe((e) => console.log(`email: ${e.sku} now ${e.price}`));
const stopPush = priceChanged.subscribe((e) => console.log(`push: ${e.sku} now ${e.price}`));

priceChanged.publish({ sku: "PHONE-1", price: 1099 });
stopPush();
priceChanged.publish({ sku: "PHONE-1", price: 999 });
email: PHONE-1 now 1099
push: PHONE-1 now 1099
email: PHONE-1 now 999

subscribe returns the function that undoes it, so nobody has to keep a reference to the subject just to leave. The part the 2022 code never tested is failure. Node.js's built-in EventEmitter is the same pattern:

import { EventEmitter } from "node:events";

const orders = new EventEmitter();
orders.on("paid", () => console.log("1. reserve stock"));
orders.on("paid", () => { throw new Error("mailer is down"); });
orders.on("paid", () => console.log("3. update analytics"));

try {
  orders.emit("paid", { id: 42 });
} catch (e) {
  console.log("emit threw:", (e as Error).message);
}
1. reserve stock
emit threw: mailer is down

The third listener never ran, and the error reached the code that published the event. emit calls listeners synchronously, in order; the Topic above behaves the same way. If one failing side effect must not block the others, catch inside each listener, or hand the work to a queue.

What I check now

The patterns have not changed since 2022; what I check has. Does the type system catch the missing case? Does the director own the steps? Does the adapter translate anything? Does the singleton mean one per thread, per process or per cluster? A class named after a pattern is not the pattern. The pattern is there only if the code does the job the name describes.

TypeScript / DesignBack to Home