Skip to main content
TypeScript · lesson 17 of 21
TypeScript intermediate Lesson 17 of 21

Also part of: Low-Level Design

Design Patterns in TypeScript

Singleton, Factory, a typed registry, a type-state Builder, Strategy and Observer in TypeScript, each run with real tsc errors and tsx output.

By SkillByExample · Updated

Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default — on 5.x add --strict to get the same errors.

Singleton

A class with a private constructor and a static accessor guarantees one instance.

class ConnectionPool {
  static #instance: ConnectionPool | undefined;
  #active = new Set<string>();

  private constructor(readonly max: number) {
    console.log(`creating pool (max ${max})`);
  }

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

  acquire(name: string): void {
    if (this.#active.size >= this.max) throw new Error(`pool exhausted, ${name} must wait`);
    this.#active.add(name);
  }
}

const a = ConnectionPool.get();
const b = ConnectionPool.get(50);
console.log(a === b, b.max);
a.acquire("web");
b.acquire("worker");
try {
  a.acquire("cron");
} catch (e) {
  console.log((e as Error).message);
}
new ConnectionPool(5);
$ npx tsc --noEmit --module nodenext singleton.ts
singleton.ts(29,1): error TS2673: Constructor of class 'ConnectionPool' is private and only accessible within the class declaration.

The private constructor forces everyone through get. Remove the last line and run it:

$ npx tsx singleton.ts
creating pool (max 2)
true 2
pool exhausted, cron must wait

The constructor ran once, and get(50) silently ignored its argument — the pool already existed. That’s the Singleton’s real cost: configuration and hidden global state. In Node, an exported module-level object is already a singleton, because the module is evaluated once and cached:

console.log("evaluating config.ts");
export const config = { apiUrl: "http://localhost:3000", retries: 3 };
import { config } from "./config.js";
export function bump() {
  config.retries++;
}
import { config } from "./config.js";
import { bump } from "./consumer.js";

bump();
console.log(config.retries);
$ npx tsc --noEmit --module nodenext main.ts
$ npx tsx main.ts
evaluating config.ts
4

config.ts was evaluated once even though two modules import it, and a change made through one import is visible through the other.

Factory

A factory function hides which class is constructed. Callers depend only on the interface.

interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  constructor(private prefix: string) {}
  log(msg: string) { console.log(`[${this.prefix}] ${msg}`); }
}

class MemoryLogger implements Logger {
  lines: string[] = [];
  log(msg: string) { this.lines.push(msg); }
}

type LoggerConfig = { type: "console"; prefix: string } | { type: "memory" } | { type: "file"; path: string };

function createLogger(config: LoggerConfig): Logger {
  switch (config.type) {
    case "console":
      return new ConsoleLogger(config.prefix);
    case "memory":
      return new MemoryLogger();
    default:
      throw new Error(`unknown logger: ${JSON.stringify(config satisfies never)}`);
  }
}

createLogger({ type: "console", prefix: "users" }).log("service started");
createLogger({ type: "console" });
$ npx tsc --noEmit --module nodenext factory.ts
factory.ts(24,64): error TS1360: Type '{ type: "file"; path: string; }' does not satisfy the expected type 'never'.
factory.ts(29,14): error TS2345: Argument of type '{ type: "console"; }' is not assignable to parameter of type 'LoggerConfig'.
  Property 'prefix' is missing in type '{ type: "console"; }' but required in type '{ type: "console"; prefix: string; }'.

Making the config a discriminated union ties each type to the options it needs: prefix is required for "console" only. And the satisfies never check notices that "file" was added to the config type but not to the factory.

A Typed Registry

When implementations are registered at runtime — plugins, say — a registry keyed by name can still be fully typed if a type maps each name to its constructor arguments.

interface Shape { area(): number; name: string }
class Circle implements Shape { name = "circle"; constructor(private r: number) {} area() { return Math.PI * this.r ** 2; } }
class Rect implements Shape { name = "rect"; constructor(private w: number, private h: number) {} area() { return this.w * this.h; } }

type ShapeArgs = { circle: [radius: number]; rect: [width: number, height: number] };

class Registry<M extends Record<string, unknown[]>, T> {
  #ctors: { [K in keyof M]?: new (...args: M[K]) => T } = {};

  register<K extends keyof M>(name: K, ctor: new (...args: M[K]) => T): this {
    this.#ctors[name] = ctor;
    return this;
  }
  create<K extends keyof M>(name: K, ...args: M[K]): T {
    const Ctor = this.#ctors[name];
    if (!Ctor) throw new Error(`no shape registered as ${String(name)}`);
    return new Ctor(...args);
  }
}

const shapes = new Registry<ShapeArgs, Shape>().register("circle", Circle).register("rect", Rect);
for (const s of [shapes.create("circle", 1), shapes.create("rect", 2, 3)]) {
  console.log(s.name, s.area().toFixed(2));
}
shapes.create("rect", 2);
shapes.register("circle", Rect);
$ npx tsc --noEmit --module nodenext registry.ts
registry.ts(25,8): error TS2554: Expected 3 arguments, but got 2.
registry.ts(26,27): error TS2345: Argument of type 'typeof Rect' is not assignable to parameter of type 'new (radius: number) => Shape'.
  Types of construct signatures are incompatible.
    Type 'new (w: number, h: number) => Rect' is not assignable to type 'new (radius: number) => Shape'.
      Target signature provides too few arguments. Expected 2 or more, but got 1.

create("rect", 2) is missing the height, and registering Rect under "circle" fails because its constructor needs two numbers where one was declared. Remove the two bad lines:

$ npx tsx registry.ts
circle 3.14
rect 6.00

Builder

A builder assembles an object in steps. A type-state builder also tracks, in its type, which required steps have been done — so build() doesn’t exist until they have.

type Query = { table: string; columns: string[]; where: string[]; limit?: number };

class QueryBuilder<HasTable extends boolean = false> {
  private constructor(private q: Partial<Query> & { columns: string[]; where: string[] }) {}

  static create() {
    return new QueryBuilder({ columns: ["*"], where: [] });
  }
  from(table: string): QueryBuilder<true> {
    return new QueryBuilder<true>({ ...this.q, table });
  }
  select(...columns: string[]): QueryBuilder<HasTable> {
    return new QueryBuilder<HasTable>({ ...this.q, columns });
  }
  where(condition: string): QueryBuilder<HasTable> {
    return new QueryBuilder<HasTable>({ ...this.q, where: [...this.q.where, condition] });
  }
  build(this: QueryBuilder<true>): string {
    const where = this.q.where.length ? ` WHERE ${this.q.where.join(" AND ")}` : "";
    return `SELECT ${this.q.columns.join(", ")} FROM ${this.q.table}${where}`;
  }
}

console.log(QueryBuilder.create().select("id", "name").from("users").where("active = true").build());
console.log(QueryBuilder.create().where("id = 1").build());
$ npx tsc --noEmit --module nodenext builder.ts
$ npx tsx builder.ts
SELECT id, name FROM users WHERE active = true
SELECT * FROM undefined WHERE id = 1

The plan was: build declares this: QueryBuilder<true>, only from produces that type, so the second chain should be rejected. It compiled anyway and printed FROM undefined. The reason is structural typing: HasTable isn’t used by any member of the class, so QueryBuilder<false> and QueryBuilder<true> have identical shapes and are interchangeable. A type parameter only constrains anything if it appears in the structure. A declared field puts it there without emitting any code:

type Query = { table: string; columns: string[]; where: string[] };

class QueryBuilder<HasTable extends boolean = false> {
  declare private readonly hasTable: HasTable;
  private constructor(private q: Partial<Query> & { columns: string[]; where: string[] }) {}

  static create() {
    return new QueryBuilder({ columns: ["*"], where: [] });
  }
  from(table: string): QueryBuilder<true> {
    return new QueryBuilder<true>({ ...this.q, table });
  }
  select(...columns: string[]): QueryBuilder<HasTable> {
    return new QueryBuilder<HasTable>({ ...this.q, columns });
  }
  where(condition: string): QueryBuilder<HasTable> {
    return new QueryBuilder<HasTable>({ ...this.q, where: [...this.q.where, condition] });
  }
  build(this: QueryBuilder<true>): string {
    const where = this.q.where.length ? ` WHERE ${this.q.where.join(" AND ")}` : "";
    return `SELECT ${this.q.columns.join(", ")} FROM ${this.q.table}${where}`;
  }
}

console.log(QueryBuilder.create().select("id", "name").from("users").where("active = true").build());
console.log(QueryBuilder.create().where("id = 1").build());
$ npx tsc --noEmit --module nodenext builder2.ts
builder2.ts(26,13): error TS2684: The 'this' context of type 'QueryBuilder<false>' is not assignable to method's 'this' of type 'QueryBuilder<true>'.
  Type 'false' is not assignable to type 'true'.

Now the missing from is a compile error. Each step returns a new builder, so a partly built query can also be reused as a base for several others.

Strategy

Strategy swaps an algorithm at runtime. In TypeScript a strategy is often just a function type.

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

const none: Discount = () => 0;
const percent = (pct: number): Discount => (subtotal) => subtotal * (pct / 100);
const flat = (amount: number): Discount => (subtotal) => Math.min(amount, subtotal);
const best = (...options: Discount[]): Discount => (subtotal) => Math.max(...options.map((d) => d(subtotal)));

function checkout(prices: number[], discount: Discount): string {
  const subtotal = prices.reduce((a, b) => a + b, 0);
  return `subtotal ${subtotal.toFixed(2)}, total ${(subtotal - discount(subtotal)).toFixed(2)}`;
}

const cart = [19.99, 5.01, 75];
console.log(checkout(cart, none));
console.log(checkout(cart, percent(10)));
console.log(checkout(cart, flat(15)));
console.log(checkout(cart, best(percent(10), flat(15))));
console.log(checkout([8], flat(15)));
$ npx tsc --noEmit --module nodenext strategy.ts
$ npx tsx strategy.ts
subtotal 100.00, total 100.00
subtotal 100.00, total 90.00
subtotal 100.00, total 85.00
subtotal 100.00, total 85.00
subtotal 8.00, total 0.00

percent(10) and flat(15) are factories returning strategies, and best composes strategies into a new one — something a class hierarchy makes awkward. The last line shows why flat clamps with Math.min: a $15 coupon on an $8 cart gives a total of zero, not minus seven.

Observer

An observer lets objects subscribe to events without the publisher knowing who they are. Returning an unsubscribe function from on keeps cleanup simple.

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

class Emitter<Events extends Record<string, unknown>> {
  #listeners: { [K in keyof Events]?: Set<Listener<Events[K]>> } = {};

  on<K extends keyof Events>(event: K, fn: Listener<Events[K]>): () => void {
    const set = (this.#listeners[event] ??= new Set());
    set.add(fn);
    return () => set.delete(fn);
  }
  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    this.#listeners[event]?.forEach((fn) => fn(data));
  }
}

type CartEvents = {
  itemAdded: { sku: string; qty: number };
  checkedOut: { total: number };
};

const cart = new Emitter<CartEvents>();
const stopLogging = cart.on("itemAdded", (e) => console.log(`log: ${e.qty} x ${e.sku}`));
cart.on("itemAdded", (e) => { if (e.qty > 5) console.log(`alert: bulk order of ${e.sku}`); });
cart.on("checkedOut", ({ total }) => console.log(`receipt: $${total.toFixed(2)}`));

cart.emit("itemAdded", { sku: "A1", qty: 2 });
stopLogging();
cart.emit("itemAdded", { sku: "B7", qty: 10 });
cart.emit("checkedOut", { total: 142.5 });
cart.emit("checkedOut", { total: "142.50" });
$ npx tsc --noEmit --module nodenext observer.ts
observer.ts(30,27): error TS2322: Type 'string' is not assignable to type 'number'.

Remove the last line:

$ npx tsx observer.ts
log: 2 x A1
alert: bulk order of B7
receipt: $142.50

After stopLogging(), the second item produced only the alert. CartEvents is declared with type, not interface: an interface wouldn’t satisfy the Record<string, unknown> constraint, because interfaces aren’t assumed to have an index signature.

Practice

1. Add a "file" case to createLogger using a FileLogger that collects lines with the path as a prefix, so factory.ts compiles.

Solution
interface Logger { log(message: string): void }

class ConsoleLogger implements Logger {
  constructor(private prefix: string) {}
  log(msg: string) { console.log(`[${this.prefix}] ${msg}`); }
}
class FileLogger implements Logger {
  lines: string[] = [];
  constructor(private path: string) {}
  log(msg: string) { this.lines.push(`${this.path}: ${msg}`); }
}

type LoggerConfig = { type: "console"; prefix: string } | { type: "file"; path: string };

function createLogger(config: LoggerConfig): Logger {
  switch (config.type) {
    case "console": return new ConsoleLogger(config.prefix);
    case "file": return new FileLogger(config.path);
    default: throw new Error(`unknown logger: ${JSON.stringify(config satisfies never)}`);
  }
}

const file = createLogger({ type: "file", path: "/var/log/app.log" });
file.log("started");
console.log(file instanceof FileLogger ? file.lines : "not a file logger");
$ npx tsc --noEmit --module nodenext practice-1.ts
$ npx tsx practice-1.ts
[ '/var/log/app.log: started' ]

The factory’s return type is Logger, so reading lines needs an instanceof check — callers are deliberately kept from depending on the concrete class.

2. Add a buyXgetY(x, y, unitPrice) discount strategy — for every x + y items, y are free — and apply it to 7 items at $4.

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

const buyXgetY = (x: number, y: number, unitPrice: number): Discount => (subtotal) => {
  const items = Math.round(subtotal / unitPrice);
  return Math.floor(items / (x + y)) * y * unitPrice;
};

const subtotal = 7 * 4;
const discount = buyXgetY(2, 1, 4);
console.log(`subtotal ${subtotal}, discount ${discount(subtotal)}, total ${subtotal - discount(subtotal)}`);
$ npx tsc --noEmit --module nodenext practice-2.ts
$ npx tsx practice-2.ts
subtotal 28, discount 8, total 20

Seven items make two complete groups of three, so two items are free. The strategy only sees the subtotal, which is why it needs unitPrice — a sign that a richer strategy signature (taking the cart) would fit real promotions better.

3. Make the QueryBuilder also require at least one select call before build, using a second type parameter.

Solution
class QB<HasTable extends boolean = false, HasCols extends boolean = false> {
  declare private readonly state: [HasTable, HasCols];
  private constructor(private table?: string, private cols: string[] = []) {}
  static create() { return new QB(); }
  from(table: string) { return new QB<true, HasCols>(table, this.cols); }
  select(...cols: string[]) { return new QB<HasTable, true>(this.table, cols); }
  build(this: QB<true, true>): string {
    return `SELECT ${this.cols.join(", ")} FROM ${this.table}`;
  }
}

console.log(QB.create().from("users").select("id").build());
console.log(QB.create().from("users").build());
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(13,13): error TS2684: The 'this' context of type 'QB<true, false>' is not assignable to method's 'this' of type 'QB<true, true>'.
  Type 'false' is not assignable to type 'true'.

Each required step flips its own flag, and build demands both be true; the declared state field is what makes the flags part of the type. The error message spells out which flag is still false.

Frequently Asked Questions

Are design patterns different in TypeScript than in other OOP languages?
The intent is the same, but TypeScript's structural type system and generics let you express patterns more concisely. Many patterns that need interfaces and abstract classes in Java can be written with plain functions and types in TypeScript.
Should I always use design patterns?
Use patterns when they solve a real problem in your code. A pattern adds abstraction, and abstraction has a cost in readability. Don't apply patterns preemptively.
Is the Singleton pattern needed in TypeScript/Node.js?
Often not. Node.js module caching means a plain exported object is effectively a singleton. The Singleton class pattern is mainly useful when you need lazy initialization or want to enforce single instantiation in a class.
What is a type-state builder?
A builder whose type records which steps have been called, usually with a generic parameter per required field. Methods like build() are only available once the required steps are done, so forgetting one is a compile error instead of a runtime exception.
How does the Strategy pattern look without classes?
In TypeScript a strategy can simply be a function type, such as type Discount = (subtotal: number) => number. Callers pass whichever function they want, and the type checks that each one has the right signature, with no interface or class hierarchy needed.