TypeScript · lesson 17 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Also 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.