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
DocumentandDocumentEditorsubclasses (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
Computeris only finalized whenbuild()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 aFile(leaf) orDirectory(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
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.
- TypeScript Official Documentation
- Refactoring Guru: Design Patterns
- TypeScript Design Patterns GitHub Repository