TypeScript

TypeScript Design Patterns

40 min Lesson 23 of 40

TypeScript Design Patterns

Design patterns are proven solutions to common software design problems. TypeScript's type system makes implementing these patterns safer and more expressive than in vanilla JavaScript. In this lesson, we'll explore classic design patterns—Singleton, Factory, Observer, and Strategy—and see how TypeScript's features enhance their implementation.

Singleton Pattern

The Singleton pattern ensures a class has only one instance and provides a global access point to it:

// Basic Singleton implementation class DatabaseConnection { private static instance: DatabaseConnection; private connected: boolean = false; // Private constructor prevents direct instantiation private constructor( private readonly connectionString: string ) {} static getInstance(connectionString: string): DatabaseConnection { if (!DatabaseConnection.instance) { DatabaseConnection.instance = new DatabaseConnection(connectionString); } return DatabaseConnection.instance; } connect(): void { if (!this.connected) { console.log(\`Connecting to ${this.connectionString}\`); this.connected = true; } } query(sql: string): void { if (!this.connected) { throw new Error('Not connected to database'); } console.log(\`Executing: ${sql}\`); } } // Usage const db1 = DatabaseConnection.getInstance('postgres://localhost'); const db2 = DatabaseConnection.getInstance('mysql://localhost'); console.log(db1 === db2); // true - same instance // Generic Singleton pattern class Singleton<T> { private static instances = new Map<Function, unknown>(); protected constructor() {} static getInstance<T extends Singleton<any>>( this: new () => T ): T { if (!Singleton.instances.has(this)) { Singleton.instances.set(this, new this()); } return Singleton.instances.get(this) as T; } } // Using generic Singleton class Logger extends Singleton<Logger> { private logs: string[] = []; log(message: string): void { this.logs.push(\`[${new Date().toISOString()}] ${message}\`); } getLogs(): string[] { return [...this.logs]; } } const logger1 = Logger.getInstance(); const logger2 = Logger.getInstance(); console.log(logger1 === logger2); // true // Singleton with lazy initialization and thread safety class ConfigManager { private static instance: ConfigManager | null = null; private static isCreating = false; private config: Map<string, unknown> = new Map(); private constructor() {} static async getInstance(): Promise<ConfigManager> { if (ConfigManager.instance) { return ConfigManager.instance; } // Prevent race conditions while (ConfigManager.isCreating) { await new Promise(resolve => setTimeout(resolve, 10)); } if (!ConfigManager.instance) { ConfigManager.isCreating = true; ConfigManager.instance = new ConfigManager(); await ConfigManager.instance.loadConfig(); ConfigManager.isCreating = false; } return ConfigManager.instance; } private async loadConfig(): Promise<void> { // Simulate async config loading const response = await fetch('/api/config'); const data = await response.json(); Object.entries(data).forEach(([key, value]) => { this.config.set(key, value); }); } get<T>(key: string): T | undefined { return this.config.get(key) as T | undefined; } }
Note: The Singleton pattern is useful for managing shared resources like database connections, configuration, or logging. However, use it sparingly as it introduces global state.

Factory Pattern

The Factory pattern provides an interface for creating objects without specifying their exact classes:

// Product interface interface Vehicle { readonly type: string; start(): void; stop(): void; getInfo(): string; } // Concrete products class Car implements Vehicle { readonly type = 'car'; constructor( private readonly model: string, private readonly year: number ) {} start(): void { console.log(\`Starting ${this.model} car\`); } stop(): void { console.log('Car stopped'); } getInfo(): string { return \`${this.year} ${this.model}\`; } } class Motorcycle implements Vehicle { readonly type = 'motorcycle'; constructor( private readonly brand: string, private readonly cc: number ) {} start(): void { console.log(\`Starting ${this.brand} motorcycle\`); } stop(): void { console.log('Motorcycle stopped'); } getInfo(): string { return \`${this.brand} ${this.cc}cc\`; } } class Truck implements Vehicle { readonly type = 'truck'; constructor( private readonly capacity: number ) {} start(): void { console.log('Starting truck'); } stop(): void { console.log('Truck stopped'); } getInfo(): string { return \`Truck (${this.capacity}t capacity)\`; } } // Factory class class VehicleFactory { static createVehicle(type: 'car', model: string, year: number): Car; static createVehicle(type: 'motorcycle', brand: string, cc: number): Motorcycle; static createVehicle(type: 'truck', capacity: number): Truck; static createVehicle( type: string, ...args: unknown[] ): Vehicle { switch (type) { case 'car': return new Car(args[0] as string, args[1] as number); case 'motorcycle': return new Motorcycle(args[0] as string, args[1] as number); case 'truck': return new Truck(args[0] as number); default: throw new Error(\`Unknown vehicle type: ${type}\`); } } } // Usage with type safety const car = VehicleFactory.createVehicle('car', 'Tesla Model 3', 2023); const bike = VehicleFactory.createVehicle('motorcycle', 'Harley', 1200); const truck = VehicleFactory.createVehicle('truck', 10); // Abstract Factory pattern with generics interface UIComponent { render(): string; } class Button implements UIComponent { constructor(private readonly theme: string) {} render(): string { return \`<button class="${this.theme}-button">Click</button>\`; } } class Input implements UIComponent { constructor(private readonly theme: string) {} render(): string { return \`<input class="${this.theme}-input" />\`; } } abstract class UIFactory { abstract createButton(): Button; abstract createInput(): Input; createComponents(): UIComponent[] { return [this.createButton(), this.createInput()]; } } class LightThemeFactory extends UIFactory { createButton(): Button { return new Button('light'); } createInput(): Input { return new Input('light'); } } class DarkThemeFactory extends UIFactory { createButton(): Button { return new Button('dark'); } createInput(): Input { return new Input('dark'); } } // Factory with type-safe builders type VehicleConfig = { car: { model: string; year: number }; motorcycle: { brand: string; cc: number }; truck: { capacity: number }; }; class TypeSafeVehicleFactory { static create<T extends keyof VehicleConfig>( type: T, config: VehicleConfig[T] ): Vehicle { switch (type) { case 'car': const carConfig = config as VehicleConfig['car']; return new Car(carConfig.model, carConfig.year); case 'motorcycle': const bikeConfig = config as VehicleConfig['motorcycle']; return new Motorcycle(bikeConfig.brand, bikeConfig.cc); case 'truck': const truckConfig = config as VehicleConfig['truck']; return new Truck(truckConfig.capacity); default: throw new Error('Unknown type'); } } } // Type-safe usage const myCar = TypeSafeVehicleFactory.create('car', { model: 'Tesla', year: 2023 });
Tip: Use function overloads with the Factory pattern to provide strong typing for different product types while maintaining a unified interface.

Observer Pattern

The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified:

// Type-safe Observer pattern type EventMap = Record<string, unknown>; interface Observer<T> { update(data: T): void; } class Subject<T extends EventMap> { private observers = new Map<keyof T, Set<Observer<T[keyof T]>>>(); subscribe<K extends keyof T>( event: K, observer: Observer<T[K]> ): () => void { if (!this.observers.has(event)) { this.observers.set(event, new Set()); } this.observers.get(event)!.add(observer as Observer<T[keyof T]>); // Return unsubscribe function return () => { this.observers.get(event)?.delete(observer as Observer<T[keyof T]>); }; } notify<K extends keyof T>(event: K, data: T[K]): void { const observers = this.observers.get(event); if (observers) { observers.forEach(observer => observer.update(data)); } } unsubscribeAll(event?: keyof T): void { if (event) { this.observers.delete(event); } else { this.observers.clear(); } } } // Define event types type UserEvents = { login: { userId: number; timestamp: Date }; logout: { userId: number }; update: { userId: number; fields: string[] }; }; // Create subject const userSubject = new Subject<UserEvents>(); // Create observers const loginLogger: Observer<UserEvents['login']> = { update(data) { console.log(\`User ${data.userId} logged in at ${data.timestamp}\`); } }; const logoutLogger: Observer<UserEvents['logout']> = { update(data) { console.log(\`User ${data.userId} logged out\`); } }; // Subscribe const unsubscribeLogin = userSubject.subscribe('login', loginLogger); userSubject.subscribe('logout', logoutLogger); // Notify userSubject.notify('login', { userId: 1, timestamp: new Date() }); userSubject.notify('logout', { userId: 1 }); // Unsubscribe unsubscribeLogin(); // Modern EventEmitter with TypeScript class EventEmitter<T extends EventMap> { private listeners = new Map<keyof T, Set<(data: T[keyof T]) => void>>(); on<K extends keyof T>(event: K, listener: (data: T[K]) => void): this { if (!this.listeners.has(event)) { this.listeners.set(event, new Set()); } this.listeners.get(event)!.add(listener as (data: T[keyof T]) => void); return this; } off<K extends keyof T>(event: K, listener: (data: T[K]) => void): this { this.listeners.get(event)?.delete(listener as (data: T[keyof T]) => void); return this; } emit<K extends keyof T>(event: K, data: T[K]): boolean { const listeners = this.listeners.get(event); if (!listeners || listeners.size === 0) return false; listeners.forEach(listener => { try { listener(data); } catch (error) { console.error(\`Error in listener for ${String(event)}:\`, error); } }); return true; } once<K extends keyof T>(event: K, listener: (data: T[K]) => void): this { const onceWrapper = (data: T[K]) => { listener(data); this.off(event, onceWrapper); }; return this.on(event, onceWrapper); } } // Usage type AppEvents = { 'user:created': { id: number; name: string }; 'user:deleted': { id: number }; error: { message: string; code: number }; }; const emitter = new EventEmitter<AppEvents>(); emitter.on('user:created', (data) => { console.log(\`New user: ${data.name} (${data.id})\`); }); emitter.once('error', (data) => { console.log(\`Error ${data.code}: ${data.message}\`); }); emitter.emit('user:created', { id: 1, name: 'John' }); emitter.emit('error', { message: 'Connection failed', code: 500 });
Note: The Observer pattern is fundamental to event-driven architectures and reactive programming. TypeScript's generics ensure type safety for all events and their data.

Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable:

// Strategy interface interface SortStrategy<T> { sort(data: T[]): T[]; } // Concrete strategies class BubbleSort<T> implements SortStrategy<T> { sort(data: T[]): T[] { const result = [...data]; for (let i = 0; i < result.length; i++) { for (let j = 0; j < result.length - 1 - i; j++) { if (result[j] > result[j + 1]) { [result[j], result[j + 1]] = [result[j + 1], result[j]]; } } } return result; } } class QuickSort<T> implements SortStrategy<T> { sort(data: T[]): T[] { if (data.length <= 1) return data; const pivot = data[Math.floor(data.length / 2)]; const left = data.filter(x => x < pivot); const middle = data.filter(x => x === pivot); const right = data.filter(x => x > pivot); return [...this.sort(left), ...middle, ...this.sort(right)]; } } // Context class Sorter<T> { constructor(private strategy: SortStrategy<T>) {} setStrategy(strategy: SortStrategy<T>): void { this.strategy = strategy; } sort(data: T[]): T[] { return this.strategy.sort(data); } } // Usage const numbers = [5, 2, 8, 1, 9]; const sorter = new Sorter(new BubbleSort<number>()); console.log(sorter.sort(numbers)); sorter.setStrategy(new QuickSort<number>()); console.log(sorter.sort(numbers)); // Advanced strategy with function types type ValidationRule<T> = (value: T) => boolean | string; class Validator<T> { private rules: ValidationRule<T>[] = []; addRule(rule: ValidationRule<T>): this { this.rules.push(rule); return this; } validate(value: T): { valid: boolean; errors: string[] } { const errors: string[] = []; for (const rule of this.rules) { const result = rule(value); if (result === false) { errors.push('Validation failed'); } else if (typeof result === 'string') { errors.push(result); } } return { valid: errors.length === 0, errors }; } } // Usage const emailValidator = new Validator<string>() .addRule(value => value.length > 0 || 'Email is required') .addRule(value => value.includes('@') || 'Invalid email format') .addRule(value => value.length <= 100 || 'Email too long'); console.log(emailValidator.validate('test@example.com')); console.log(emailValidator.validate('invalid')); // Strategy with dependency injection interface PaymentStrategy { pay(amount: number): Promise<{ success: boolean; transactionId?: string }>; } class CreditCardPayment implements PaymentStrategy { constructor( private readonly cardNumber: string, private readonly cvv: string ) {} async pay(amount: number): Promise<{ success: boolean; transactionId?: string }> { console.log(\`Processing credit card payment of $${amount}\`); // Simulate payment processing return { success: true, transactionId: 'CC-' + Date.now() }; } } class PayPalPayment implements PaymentStrategy { constructor(private readonly email: string) {} async pay(amount: number): Promise<{ success: boolean; transactionId?: string }> { console.log(\`Processing PayPal payment of $${amount}\`); return { success: true, transactionId: 'PP-' + Date.now() }; } } class CryptoPayment implements PaymentStrategy { constructor(private readonly walletAddress: string) {} async pay(amount: number): Promise<{ success: boolean; transactionId?: string }> { console.log(\`Processing crypto payment of $${amount}\`); return { success: true, transactionId: 'CR-' + Date.now() }; } } class PaymentProcessor { constructor(private strategy: PaymentStrategy) {} setStrategy(strategy: PaymentStrategy): void { this.strategy = strategy; } async processPayment(amount: number): Promise<void> { const result = await this.strategy.pay(amount); if (result.success) { console.log(\`Payment successful: ${result.transactionId}\`); } else { console.log('Payment failed'); } } } // Usage const processor = new PaymentProcessor( new CreditCardPayment('1234-5678-9012-3456', '123') ); await processor.processPayment(100); processor.setStrategy(new PayPalPayment('user@example.com')); await processor.processPayment(50);
Warning: While design patterns are powerful, don't force them into your code. Use patterns when they naturally solve a problem, not just for the sake of using them.
Exercise:
  1. Implement a Builder pattern for creating complex configuration objects with TypeScript, ensuring type safety for required and optional fields.
  2. Create a Decorator pattern implementation for adding logging, caching, and validation to class methods using TypeScript decorators.
  3. Build a Command pattern system for implementing undo/redo functionality with proper typing for different command types.
  4. Implement the Adapter pattern to create a unified interface for multiple third-party APIs with different structures.
  5. Create a Chain of Responsibility pattern for request handling with type-safe handler registration and data passing.

Summary

TypeScript enhances classic design patterns with strong typing, making them safer and more expressive. The Singleton pattern manages shared resources, Factory creates objects flexibly, Observer implements event-driven architectures, and Strategy encapsulates interchangeable algorithms. By leveraging TypeScript's generics, interfaces, and type inference, you can implement these patterns with compile-time safety that prevents many runtime errors.