javascriptroom guide

Design Patterns in TypeScript: Implementing the Classic Solutions

Design patterns are reusable solutions to common software design problems. They serve as blueprints for solving issues like object creation, structural organization, and behavior communication in a way that’s flexible, maintainable, and scalable. While design patterns are language-agnostic, TypeScript—with its static typing, interfaces, and class-based syntax—provides powerful tools to enforce these patterns rigorously, reducing bugs and improving code clarity. This blog explores **10 classic design patterns** across three categories: Creational (object creation), Structural (object composition), and Behavioral (object interaction). For each pattern, we’ll break down its purpose, provide a practical TypeScript implementation, discuss use cases, and highlight potential pitfalls. Whether you’re a beginner or an experienced developer, this guide will help you apply these timeless solutions to real-world TypeScript projects.

Table of Contents

Creational Patterns

Creational patterns focus on object creation mechanisms, abstracting the instantiation process to make code more flexible and independent of specific classes.

1. Singleton

Overview

Ensures a class has only one instance and provides a global point of access to it. Ideal for scenarios where a single shared resource (e.g., a logger, database connection, or configuration manager) is needed.

TypeScript Implementation

To enforce a single instance, we use a private constructor (preventing direct instantiation) and a static method to control access to the instance.

class Logger {
  // Private static instance to hold the single instance
  private static instance: Logger;

  // Private constructor to prevent external instantiation
  private constructor() {}

  // Static method to get the singleton instance
  public static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger(); // Create instance if none exists
    }
    return Logger.instance;
  }

  // Example method: log a message
  public log(message: string): void {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
}

// Usage
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();

console.log(logger1 === logger2); // true (same instance)
logger1.log("Hello, Singleton!"); // [2024-05-20T12:34:56.789Z] Hello, Singleton!

Key Features:

  • Private constructor: Blocks new Logger() from external code.
  • Lazy initialization: The instance is created only when getInstance() is first called (saves memory).
  • Global access: Ensures all parts of the app use the same instance.

When to Use:

  • Managing shared resources (e.g., database connections).
  • Centralized logging or configuration.

Pitfalls:

  • Global state: Can make testing harder (e.g., mocking the singleton).
  • Concurrency risks: In multi-threaded environments, use locks to prevent race conditions (TypeScript/JS is single-threaded, so this is less of an issue).

2. Factory Method

Overview

Defines an interface for creating objects but lets subclasses decide which class to instantiate. It promotes loose coupling by delegating object creation to subclasses.

TypeScript Implementation

Example: A document editor where different subclasses (e.g., TextEditor, SpreadsheetEditor) create specific document types (TextDocument, SpreadsheetDocument).

// Product: Interface for documents
interface Document {
  open(): void;
  save(): void;
}

// Concrete Products: Specific document types
class TextDocument implements Document {
  open(): void { console.log("Opening Text Document..."); }
  save(): void { console.log("Saving Text Document..."); }
}

class SpreadsheetDocument implements Document {
  open(): void { console.log("Opening Spreadsheet Document..."); }
  save(): void { console.log("Saving Spreadsheet Document..."); }
}

// Creator: Abstract class defining the factory method
abstract class DocumentEditor {
  // Factory Method: Subclasses implement this to create products
  protected abstract createDocument(): Document;

  // Template Method: Uses the product created by the factory method (open/save)
  public newDocument(): void {
    const doc = this.createDocument();
    doc.open();
    doc.save();
  }
}

// Concrete Creators: Implement the factory method to create specific products
class TextEditor extends DocumentEditor {
  protected createDocument(): Document {
    return new TextDocument(); // Creates TextDocument
  }
}

class SpreadsheetEditor extends DocumentEditor {
  protected createDocument(): Document {
    return new SpreadsheetDocument(); // Creates SpreadsheetDocument
  }
}

// Usage
const textEditor = new TextEditor();
textEditor.newDocument(); 
// Output: Opening Text Document... Saving Text Document...

const spreadsheetEditor = new SpreadsheetEditor();
spreadsheetEditor.newDocument(); 
// Output: Opening Spreadsheet Document... Saving Spreadsheet Document...

Key Features:

  • Separation of concerns: The creator (DocumentEditor) focuses on using the product, not creating it.
  • Extensibility: Add new document types by creating new Document and DocumentEditor subclasses (no changes to existing code).

When to Use:

  • When a class can’t anticipate the type of objects it needs to create.
  • When you want to localize object creation logic.

Pitfalls:

  • Can lead to a proliferation of subclasses if overused.

3. Builder

Overview

Separates the construction of a complex object from its representation, allowing the same construction process to create different representations. Useful for building objects with many optional or configurable parts (e.g., a computer with optional GPU/SSD).

TypeScript Implementation

Example: Building a Computer with configurable components (CPU, GPU, SSD).

// Product: The complex object to build
class Computer {
  constructor(
    public cpu: string,
    public gpu?: string, // Optional
    public ssd?: string, // Optional
    public ram: string = "8GB" // Default
  ) {}

  public specs(): string {
    return `CPU: ${this.cpu}, RAM: ${this.ram}, GPU: ${this.gpu || "None"}, SSD: ${this.ssd || "None"}`;
  }
}

// Builder Interface: Defines steps to build the product
interface ComputerBuilder {
  setCPU(cpu: string): ComputerBuilder;
  setGPU(gpu: string): ComputerBuilder;
  setSSD(ssd: string): ComputerBuilder;
  setRAM(ram: string): ComputerBuilder;
  build(): Computer;
}

// Concrete Builder: Implements the building steps
class BasicComputerBuilder implements ComputerBuilder {
  private computer: Computer;

  constructor() {
    this.computer = new Computer(""); // Start with empty CPU (will be set later)
  }

  setCPU(cpu: string): ComputerBuilder {
    this.computer.cpu = cpu;
    return this; // Enable method chaining
  }

  setGPU(gpu: string): ComputerBuilder {
    this.computer.gpu = gpu;
    return this;
  }

  setSSD(ssd: string): ComputerBuilder {
    this.computer.ssd = ssd;
    return this;
  }

  setRAM(ram: string): ComputerBuilder {
    this.computer.ram = ram;
    return this;
  }

  build(): Computer {
    if (!this.computer.cpu) throw new Error("CPU is required!");
    return this.computer;
  }
}

// Director: Orchestrates the building process (optional but helpful for predefined configurations)
class ComputerDirector {
  constructor(private builder: ComputerBuilder) {}

  buildGamingPC(): Computer {
    return this.builder
      .setCPU("Intel i9")
      .setGPU("NVIDIA RTX 4090")
      .setSSD("2TB NVMe")
      .setRAM("32GB")
      .build();
  }

  buildOfficePC(): Computer {
    return this.builder
      .setCPU("Intel i5")
      .setRAM("16GB")
      .build(); // No GPU/SSD
  }
}

// Usage
const builder = new BasicComputerBuilder();
const director = new ComputerDirector(builder);

const gamingPC = director.buildGamingPC();
console.log(gamingPC.specs()); 
// CPU: Intel i9, RAM: 32GB, GPU: NVIDIA RTX 4090, SSD: 2TB NVMe

const officePC = director.buildOfficePC();
console.log(officePC.specs()); 
// CPU: Intel i5, RAM: 16GB, GPU: None, SSD: None

Key Features:

  • Control over construction: Step-by-step building with optional components.
  • Immutability: The Computer is only finalized when build() is called.
  • Reusability: The same builder can create different configurations (via the director).

When to Use:

  • Building objects with many optional or complex components.
  • When you want to isolate the construction logic from the object’s representation.

Pitfalls:

  • Adds complexity (extra classes for builders/director) for simple objects.

Structural Patterns

Structural patterns focus on how objects and classes are composed to form larger structures while keeping them flexible and efficient.

4. Adapter

Overview

Converts the interface of a class into another interface that clients expect. Lets incompatible classes work together by wrapping an existing class with a new interface.

TypeScript Implementation

Example: Adapting an old LegacyLogger (with logMessage) to a new ILogger interface (with log).

// Target Interface: What the client expects
interface ILogger { log(info: { timestamp: Date; message: string }): void; }

// Adaptee: The existing class with an incompatible interface
class LegacyLogger {
  // Old method: Only accepts a string message
  public logMessage(message: string): void {
    console.log(`[Legacy] ${message}`);
  }
}

// Adapter: Wraps the Adaptee to implement the Target Interface
class LoggerAdapter implements ILogger {
  private legacyLogger: LegacyLogger;

  constructor(legacyLogger: LegacyLogger) {
    this.legacyLogger = legacyLogger;
  }

  // Adapt the new interface to the old method
  log(info: { timestamp: Date; message: string }): void {
    const formattedMessage = `[${info.timestamp.toISOString()}] ${info.message}`;
    this.legacyLogger.logMessage(formattedMessage); // Delegate to LegacyLogger
  }
}

// Client: Uses the Target Interface
class Client {
  constructor(private logger: ILogger) {}

  doWork(): void {
    this.logger.log({ timestamp: new Date(), message: "Work done!" });
  }
}

// Usage
const legacyLogger = new LegacyLogger();
const adapter = new LoggerAdapter(legacyLogger); // Wrap LegacyLogger
const client = new Client(adapter); // Client uses the adapter

client.doWork(); 
// Output: [Legacy] [2024-05-20T12:34:56.789Z] Work done!

Key Features:

  • Reusability: Lets you use existing classes with new interfaces without modifying them.
  • Decoupling: Clients depend on the target interface, not the adaptee.

When to Use:

  • Integrating legacy code with new systems.
  • Using third-party libraries with incompatible interfaces.

Pitfalls:

  • Adding an adapter layer can introduce minor performance overhead.

5. Decorator

Overview

Dynamically attaches additional responsibilities to an object. Provides a flexible alternative to subclassing for extending functionality (e.g., adding milk/sugar to coffee).

TypeScript Implementation

Example: Decorating a Coffee with optional add-ons (Milk, Sugar).

// Component: The base interface for objects to be decorated
interface Coffee {
  cost(): number;
  description(): string;
}

// Concrete Component: Basic object without decorations
class BasicCoffee implements Coffee {
  cost(): number { return 2.5; }
  description(): string { return "Basic Coffee"; }
}

// Decorator: Abstract class implementing the Coffee interface (wraps a Coffee)
abstract class CoffeeDecorator implements Coffee {
  constructor(protected coffee: Coffee) {}

  // Delegate to the wrapped coffee by default
  cost(): number { return this.coffee.cost(); }
  description(): string { return this.coffee.description(); }
}

// Concrete Decorators: Add specific responsibilities
class MilkDecorator extends CoffeeDecorator {
  cost(): number { return this.coffee.cost() + 0.5; } // Add milk cost
  description(): string { return `${this.coffee.description()}, Milk`; }
}

class SugarDecorator extends CoffeeDecorator {
  cost(): number { return this.coffee.cost() + 0.2; } // Add sugar cost
  description(): string { return `${this.coffee.description()}, Sugar`; }
}

// Usage: Decorate the coffee dynamically
let coffee: Coffee = new BasicCoffee();
console.log(coffee.description()); // Basic Coffee
console.log(coffee.cost()); // 2.5

// Add milk
coffee = new MilkDecorator(coffee);
console.log(coffee.description()); // Basic Coffee, Milk
console.log(coffee.cost()); // 3.0

// Add sugar
coffee = new SugarDecorator(coffee);
console.log(coffee.description()); // Basic Coffee, Milk, Sugar
console.log(coffee.cost()); // 3.2

Key Features:

  • Dynamic extension: Add/remove responsibilities at runtime (e.g., add milk, then remove it).
  • Single responsibility: Each decorator handles one addition (Milk, Sugar), making code modular.

When to Use:

  • Adding features to objects dynamically (without subclassing).
  • When you need to combine multiple features (e.g., milk + sugar).

Pitfalls:

  • Can lead to a large number of small decorator classes.

6. Composite

Overview

Composes objects into tree structures to represent part-whole hierarchies. Treats individual objects and compositions of objects uniformly (e.g., files and directories in a filesystem).

TypeScript Implementation

Example: A filesystem with File (leaf) and Directory (composite) nodes.

// Component: Common interface for leaves and composites
interface FileSystemItem {
  getName(): string;
  getSize(): number; // Size in KB
}

// Leaf: Individual object (no children)
class File implements FileSystemItem {
  constructor(private name: string, private sizeKB: number) {}

  getName(): string { return this.name; }
  getSize(): number { return this.sizeKB; }
}

// Composite: Object with children (can contain leaves or other composites)
class Directory implements FileSystemItem {
  private children: FileSystemItem[] = [];

  constructor(private name: string) {}

  getName(): string { return this.name; }

  // Add a child (file or directory)
  add(item: FileSystemItem): void { this.children.push(item); }

  // Remove a child
  remove(item: FileSystemItem): void { 
    this.children = this.children.filter(child => child !== item); 
  }

  // Total size = sum of children's sizes
  getSize(): number { 
    return this.children.reduce((total, child) => total + child.getSize(), 0); 
  }
}

// Usage: Build a filesystem tree
const rootDir = new Directory("Root");
const docsDir = new Directory("Documents");
const resumeFile = new File("resume.pdf", 2048); // 2MB
const codeDir = new Directory("Code");
const appFile = new File("app.ts", 512); // 512KB

// Build hierarchy
docsDir.add(resumeFile);
codeDir.add(appFile);
rootDir.add(docsDir);
rootDir.add(codeDir);

// Treat leaves and composites uniformly
console.log(`Root size: ${rootDir.getSize()} KB`); // 2048 + 512 = 2560 KB
console.log(`Documents size: ${docsDir.getSize()} KB`); // 2048 KB

Key Features:

  • Uniform treatment: Call getSize() on a File (leaf) or Directory (composite) with the same interface.
  • Recursive structure: Composites can contain other composites, enabling deep hierarchies.

When to Use:

  • Representing part-whole hierarchies (e.g., files/directories, UI components).
  • When clients need to ignore the difference between individual objects and compositions.

Pitfalls:

  • Can make navigation/modification of the tree complex (e.g., deleting a nested directory).

Behavioral Patterns

Behavioral patterns focus on communication between objects, defining how objects interact and distribute responsibility.

7. Observer

Overview

Defines a one-to-many dependency between objects: when one object (subject) changes state, all its dependents (observers) are notified and updated automatically.

TypeScript Implementation

Example: A WeatherStation (subject) notifying PhoneDisplay and LaptopDisplay (observers) of temperature changes.

// Observer Interface: Defines update method for observers
interface Observer {
  update(temperature: number): void;
}

// Subject Interface: Defines methods to manage observers
interface Subject {
  attach(observer: Observer): void;
  detach(observer: Observer): void;
  notify(): void; // Notify all observers of state change
}

// Concrete Subject: The object being observed (WeatherStation)
class WeatherStation implements Subject {
  private temperature: number = 0;
  private observers: Observer[] = [];

  // Attach an observer
  attach(observer: Observer): void { this.observers.push(observer); }

  // Detach an observer
  detach(observer: Observer): void {
    this.observers = this.observers.filter(obs => obs !== observer);
  }

  // Notify all observers of the new temperature
  notify(): void {
    this.observers.forEach(observer => observer.update(this.temperature));
  }

  // Set new temperature and trigger notification
  setTemperature(temp: number): void {
    this.temperature = temp;
    this.notify(); // Notify observers on state change
  }
}

// Concrete Observers: React to updates
class PhoneDisplay implements Observer {
  update(temperature: number): void {
    console.log(`Phone Display: Temperature is ${temperature}°C`);
  }
}

class LaptopDisplay implements Observer {
  update(temperature: number): void {
    console.log(`Laptop Display: Current Temp: ${temperature}°C`);
  }
}

// Usage
const weatherStation = new WeatherStation();
const phone = new PhoneDisplay();
const laptop = new LaptopDisplay();

weatherStation.attach(phone);
weatherStation.attach(laptop);

weatherStation.setTemperature(22); 
// Output:
// Phone Display: Temperature is 22°C
// Laptop Display: Current Temp: 22°C

weatherStation.detach(phone); // Phone no longer observes
weatherStation.setTemperature(25); 
// Output: Laptop Display: Current Temp: 25°C

Key Features:

  • Decoupling: Subject and observers are loosely coupled (subject doesn’t know observer details).
  • Dynamic updates: Observers can attach/detach at runtime.

When to Use:

  • When an object’s changes affect multiple others (e.g., UI updates, event handling).

Pitfalls:

  • Observers may receive unnecessary updates if not managed carefully.

8. Strategy

Overview

Defines a family of algorithms, encapsulates each one, and makes them interchangeable. Lets the algorithm vary independently from clients that use it (e.g., different payment methods).

TypeScript Implementation

Example: A PaymentProcessor with interchangeable payment strategies (Credit Card, PayPal).

// Strategy Interface: Common interface for all algorithms
interface PaymentStrategy {
  pay(amount: number): void;
}

// Concrete Strategies: Implement specific algorithms
class CreditCardPayment implements PaymentStrategy {
  constructor(private cardNumber: string) {}

  pay(amount: number): void {
    console.log(`Paid $${amount} with Credit Card (****${this.cardNumber.slice(-4)})`);
  }
}

class PayPalPayment implements PaymentStrategy {
  constructor(private email: string) {}

  pay(amount: number): void {
    console.log(`Paid $${amount} via PayPal (${this.email})`);
  }
}

// Context: Uses a strategy to perform work
class PaymentProcessor {
  private strategy: PaymentStrategy;

  // Set strategy dynamically
  setStrategy(strategy: PaymentStrategy): void {
    this.strategy = strategy;
  }

  // Delegate payment to the strategy
  checkout(amount: number): void {
    if (!this.strategy) throw new Error("No payment strategy set");
    this.strategy.pay(amount);
  }
}

// Usage: Switch strategies at runtime
const processor = new PaymentProcessor();

// Pay with Credit Card
processor.setStrategy(new CreditCardPayment("4111-1111-1111-1234"));
processor.checkout(99.99); 
// Paid $99.99 with Credit Card (****1234)

// Switch to PayPal
processor.setStrategy(new PayPalPayment("[email protected]"));
processor.checkout(49.99); 
// Paid $49.99 via PayPal ([email protected])

Key Features:

  • Flexibility: Swap algorithms without changing client code (e.g., add Bitcoin payments later).
  • Open/Closed Principle: Add new strategies without modifying existing ones.

When to Use:

  • When you have multiple algorithms for a specific task and want to switch between them.

Pitfalls:

  • Clients must be aware of different strategies to select the right one.

9. Command

Overview

Encapsulates a request as an object, allowing parameterization of clients with queues, requests, or operations. Supports undo/redo functionality by storing command history.

TypeScript Implementation

Example: A RemoteControl with buttons that execute commands (turn light on/off).

// Command Interface: Defines execute/undo methods
interface Command {
  execute(): void;
  undo(): void;
}

// Receiver: The object that performs the actual action
class Light {
  private isOn: boolean = false;

  on(): void { 
    this.isOn = true; 
    console.log("Light is ON"); 
  }

  off(): void { 
    this.isOn = false; 
    console.log("Light is OFF"); 
  }
}

// Concrete Commands: Encapsulate a request (bind receiver and action)
class LightOnCommand implements Command {
  constructor(private light: Light) {}

  execute(): void { this.light.on(); }
  undo(): void { this.light.off(); } // Undo = reverse the action
}

class LightOffCommand implements Command {
  constructor(private light: Light) {}

  execute(): void { this.light.off(); }
  undo(): void { this.light.on(); } // Undo = reverse the action
}

// Invoker: Sends commands to execute
class RemoteControl {
  private commandHistory: Command[] = [];

  // Set a command for a button
  setCommand(command: Command): void {
    this.commandHistory.push(command); // Track history for undo
    command.execute();
  }

  // Undo the last command
  undo(): void {
    const lastCommand = this.commandHistory.pop();
    if (lastCommand) lastCommand.undo();
  }
}

// Usage
const light = new Light();
const remote = new RemoteControl();

// Press "ON" button
remote.setCommand(new LightOnCommand(light)); // Light is ON

// Press "OFF" button
remote.setCommand(new LightOffCommand(light)); // Light is OFF

// Undo last action (OFF → ON)
remote.undo(); // Light is ON

Key Features:

  • Undo/redo: Commands can store state to reverse actions.
  • Decoupling: Invoker (remote) doesn’t know the receiver (light) or action details.

When to Use:

  • Implementing undo/redo, queuing requests, or logging operations.

Pitfalls:

  • Can lead to many command classes for complex systems.

Conclusion

Design patterns are not silver bullets, but they provide proven solutions to recurring problems in software design. TypeScript’s static typing, interfaces, and class-based syntax make these patterns more expressive and less error-prone to implement. By mastering patterns like Singleton, Observer, and Strategy, you’ll write code that’s more flexible, maintainable, and scalable.

Remember: Use patterns judiciously. Always understand the problem first, then choose the pattern that fits—not the other way around!

References