Skip to main content
TypeScript intermediate Lesson 17 of 21

Design Patterns in TypeScript

Implement classic design patterns — Singleton, Factory, Builder, and Strategy — with full type safety in TypeScript.

Singleton

The Singleton pattern ensures only one instance of a class exists across the entire application. This matters when an object manages shared state or a limited resource — a database connection pool, a configuration store, a logger — where creating multiple instances would either waste resources or lead to inconsistent state. TypeScript makes the pattern explicit by making the constructor private, which the compiler enforces at every call site.

class DatabasePool {
  private static instance: DatabasePool | null = null;
  private connections: Map<string, unknown> = new Map();
  private readonly maxConnections: number;

  // private constructor — only getInstance() can create the object
  private constructor(maxConnections: number = 10) {
    this.maxConnections = maxConnections;
  }

  // The single access point — creates on first call, returns cached on subsequent calls
  static getInstance(maxConnections?: number): DatabasePool {
    if (!DatabasePool.instance) {
      DatabasePool.instance = new DatabasePool(maxConnections);
    }
    return DatabasePool.instance;
  }

  acquire(name: string): void {
    if (this.connections.size >= this.maxConnections) {
      throw new Error("Connection pool exhausted");
    }
    this.connections.set(name, {});
  }

  release(name: string): void {
    this.connections.delete(name);
  }

  get activeCount(): number {
    return this.connections.size;
  }

  // For testing — allows resetting the singleton between test cases
  static reset(): void {
    DatabasePool.instance = null;
  }
}

const pool1 = DatabasePool.getInstance();
const pool2 = DatabasePool.getInstance();
console.log(pool1 === pool2); // true — same object

Module-level Singleton — simpler and sufficient for most cases. Node.js caches module exports after the first require/import, so a plain exported object is already a singleton without any class ceremony:

// config.ts — this object is created once and shared everywhere it's imported
interface AppConfig {
  apiUrl: string;
  timeout: number;
  debug: boolean;
}

const config: AppConfig = {
  apiUrl: process.env.API_URL ?? "http://localhost:3000",
  timeout: Number(process.env.TIMEOUT ?? 5000),
  debug: process.env.NODE_ENV === "development",
};

export default config; // Node.js caches this module — same object everywhere

Factory

The Factory pattern creates objects without exposing the construction logic to the caller. This is valuable when the exact type of object to create depends on configuration or runtime conditions, when construction is complex, or when you want to decouple consumers from concrete implementations. TypeScript’s discriminated unions make the factory function exhaustive — add a new type to the union and TypeScript will error until you handle it.

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

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

class FileLogger implements Logger {
  constructor(private path: string, private prefix: string) {}
  log(msg: string) { /* write to file */ }
  error(msg: string) { /* write to file */ }
  warn(msg: string) { /* write to file */ }
}

class NoOpLogger implements Logger {
  log() {}
  error() {}
  warn() {}
}

type LoggerType = "console" | "file" | "noop";

interface LoggerConfig {
  type: LoggerType;
  prefix?: string;
  filePath?: string;
}

// The factory hides which class gets constructed — callers only see Logger
function createLogger(config: LoggerConfig): Logger {
  switch (config.type) {
    case "console":
      return new ConsoleLogger(config.prefix ?? "App");
    case "file":
      if (!config.filePath) throw new Error("filePath required for file logger");
      return new FileLogger(config.filePath, config.prefix ?? "App");
    case "noop":
      return new NoOpLogger();
    default:
      // Exhaustive check — TypeScript errors here if a new LoggerType is added
      const _: never = config.type;
      throw new Error(`Unknown logger type`);
  }
}

// Usage — the caller doesn't know or care which Logger subclass was created
const logger = createLogger({ type: "console", prefix: "UserService" });
logger.log("Service started");

Generic Factory with registry — when the set of types is open-ended or plugin-driven, a registry pattern lets you register and create types dynamically without modifying the factory function itself:

type Constructor<T> = new (...args: any[]) => T;

class Registry<T> {
  private creators = new Map<string, Constructor<T>>();

  // Register a new type by name — can be called at module load time
  register(name: string, ctor: Constructor<T>): void {
    this.creators.set(name, ctor);
  }

  // Create an instance by name — throws if name was never registered
  create(name: string, ...args: any[]): T {
    const Ctor = this.creators.get(name);
    if (!Ctor) throw new Error(`Unknown type: ${name}`);
    return new Ctor(...args);
  }
}

const loggerRegistry = new Registry<Logger>();
loggerRegistry.register("console", ConsoleLogger);
loggerRegistry.register("file", FileLogger);

const logger = loggerRegistry.create("console", "MyApp");

Builder

The Builder pattern constructs complex objects step by step, allowing you to set only the options you care about and validate them before creating the final object. It’s ideal when a constructor would otherwise require many parameters — most optional — or when valid combinations of options need to be enforced. Method chaining (returning this from each setter) keeps the call site readable.

interface QueryOptions {
  table: string;
  conditions: string[];
  columns: string[];
  orderBy?: string;
  orderDir?: "ASC" | "DESC";
  limit?: number;
  offset?: number;
}

class QueryBuilder {
  private options: Partial<QueryOptions> = {
    conditions: [],
    columns: [],
  };

  // Each method returns `this` so calls can be chained
  from(table: string): this {
    this.options.table = table;
    return this;
  }

  select(...columns: string[]): this {
    this.options.columns = columns;
    return this;
  }

  where(condition: string): this {
    this.options.conditions!.push(condition);
    return this;
  }

  orderBy(column: string, direction: "ASC" | "DESC" = "ASC"): this {
    this.options.orderBy = column;
    this.options.orderDir = direction;
    return this;
  }

  take(limit: number): this {
    this.options.limit = limit;
    return this;
  }

  skip(offset: number): this {
    this.options.offset = offset;
    return this;
  }

  // build() is the only place that validates required options
  build(): string {
    if (!this.options.table) throw new Error("table is required");
    const cols = this.options.columns!.length > 0
      ? this.options.columns!.join(", ")
      : "*";
    let query = `SELECT ${cols} FROM ${this.options.table}`;
    if (this.options.conditions!.length > 0) {
      query += ` WHERE ${this.options.conditions!.join(" AND ")}`;
    }
    if (this.options.orderBy) {
      query += ` ORDER BY ${this.options.orderBy} ${this.options.orderDir ?? "ASC"}`;
    }
    if (this.options.limit !== undefined) {
      query += ` LIMIT ${this.options.limit}`;
    }
    if (this.options.offset !== undefined) {
      query += ` OFFSET ${this.options.offset}`;
    }
    return query;
  }
}

// The chained API reads almost like plain English
const sql = new QueryBuilder()
  .from("users")
  .select("id", "name", "email")
  .where("active = true")
  .where("role = 'admin'")
  .orderBy("name")
  .take(20)
  .skip(40)
  .build();
// SELECT id, name, email FROM users WHERE active = true AND role = 'admin' ORDER BY name ASC LIMIT 20 OFFSET 40

Strategy

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable at runtime. Instead of a single function with a growing if/else or switch, you split each algorithm into its own object or function and pass the desired one as a parameter. This makes adding new algorithms trivial — no existing code changes — and makes each algorithm independently testable.

interface SortStrategy<T> {
  sort(items: T[], compareFn: (a: T, b: T) => number): T[];
}

// A specific algorithm — slow but educational
class BubbleSort<T> implements SortStrategy<T> {
  sort(items: T[], compare: (a: T, b: T) => number): T[] {
    const arr = [...items]; // never mutate the input
    for (let i = 0; i < arr.length; i++) {
      for (let j = 0; j < arr.length - i - 1; j++) {
        if (compare(arr[j], arr[j + 1]) > 0) {
          [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
        }
      }
    }
    return arr;
  }
}

// The fast default — delegates to the JS runtime's sort
class NativeSort<T> implements SortStrategy<T> {
  sort(items: T[], compare: (a: T, b: T) => number): T[] {
    return [...items].sort(compare);
  }
}

// The context — holds a strategy and delegates sorting to it
class Sorter<T> {
  constructor(private strategy: SortStrategy<T>) {}

  // Swap strategies at runtime without changing the Sorter class
  setStrategy(strategy: SortStrategy<T>): void {
    this.strategy = strategy;
  }

  sort(items: T[], compare: (a: T, b: T) => number): T[] {
    return this.strategy.sort(items, compare);
  }
}

const users: User[] = [/* ... */];
const sorter = new Sorter<User>(new NativeSort());
const sorted = sorter.sort(users, (a, b) => a.name.localeCompare(b.name));

Functional Strategy — TypeScript’s first-class functions make Strategy even simpler. Instead of classes, define strategies as functions that share the same signature and pass them directly. This is often cleaner than the class-based approach for pure computation:

type PricingStrategy = (basePrice: number, quantity: number) => number;

// Each strategy is a plain function — easy to test, easy to compose
const standardPricing: PricingStrategy = (price, qty) => price * qty;

const bulkPricing: PricingStrategy = (price, qty) => {
  if (qty >= 100) return price * qty * 0.7;  // 30% discount
  if (qty >= 50) return price * qty * 0.85;  // 15% discount
  return price * qty;
};

const subscriptionPricing: PricingStrategy = (price, qty) =>
  price * qty * 0.6; // 40% discount for subscribers

// The strategy is injected as a parameter — swap it without changing this function
function calculateTotal(
  price: number,
  qty: number,
  strategy: PricingStrategy
): number {
  return strategy(price, qty);
}

calculateTotal(10, 100, bulkPricing);        // 700
calculateTotal(10, 5, standardPricing);      // 50
calculateTotal(10, 200, subscriptionPricing); // 1200

Observer

The Observer pattern (also called publish/subscribe) lets objects subscribe to events and be notified when they occur, without the publisher knowing anything about its subscribers. This decouples components that need to react to state changes from the components that produce those changes. TypeScript generics let you make the event map fully typed — the listener for "itemAdded" gets the itemAdded payload type, not a generic any.

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

// A typed event emitter — TEventMap maps event names to their payload types
class EventEmitter<TEventMap extends Record<string, unknown>> {
  private listeners = new Map<
    keyof TEventMap,
    Set<Listener<TEventMap[keyof TEventMap]>>
  >();

  // Returns an unsubscribe function — callers can clean up without keeping a ref to the listener
  on<K extends keyof TEventMap>(event: K, listener: Listener<TEventMap[K]>): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as any);
    return () => this.off(event, listener);
  }

  off<K extends keyof TEventMap>(event: K, listener: Listener<TEventMap[K]>): void {
    this.listeners.get(event)?.delete(listener as any);
  }

  emit<K extends keyof TEventMap>(event: K, data: TEventMap[K]): void {
    this.listeners.get(event)?.forEach((l) => l(data));
  }
}

// Define the event map — TypeScript ensures payloads match at every emit() and on() call
interface CartEvents {
  itemAdded: { productId: string; quantity: number };
  itemRemoved: { productId: string };
  cleared: void;
}

const cart = new EventEmitter<CartEvents>();

// TypeScript infers the payload type from the event name
const unsubscribe = cart.on("itemAdded", ({ productId, quantity }) => {
  console.log(`Added ${quantity}x ${productId}`);
});

cart.emit("itemAdded", { productId: "SKU-1", quantity: 2 });
unsubscribe(); // clean up the listener when done

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 and safely. Patterns like Strategy and Factory become simpler because TypeScript duck-types objects — you don't need verbose interface hierarchies.
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. Reach for them when you see concrete duplication or coupling, not 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 more useful when you need lazy initialization or when the instance is managed by a DI container.