Design Patterns in TypeScript

Difficulty: Advanced

Design patterns are reusable solutions to common software design problems, and TypeScript's type system makes implementing them both safer and more expressive than in plain JavaScript. TypeScript's access modifiers (private, protected, public), interfaces, generics, and abstract classes provide the language features needed to implement classical design patterns with proper encapsulation and type safety.

The Singleton pattern ensures a class has only one instance and provides a global point of access to it. In TypeScript, you make the constructor private to prevent external instantiation and expose a static `getInstance()` method. This is commonly used for configuration managers, logging services, and database connection pools. TypeScript's private constructor provides a compile-time guarantee that no one can create additional instances.

The Factory pattern provides an interface for creating objects without specifying their exact class. In TypeScript, you define an interface or abstract class for the product, then implement concrete products and a factory function or class that returns the appropriate type. TypeScript's discriminated unions and type narrowing make the factory pattern particularly elegant - you can use a string literal type as the discriminant and get fully typed return values.

The Observer pattern defines a one-to-many dependency between objects, so that when one object changes state, all its dependents are notified. In TypeScript, you use generics to type the event data and interfaces to define the subscriber contract. This pattern is the foundation of event emitters, reactive programming, and state management libraries. TypeScript generics ensure that subscribers receive correctly typed event data, catching type mismatches at compile time.

Code examples

Singleton Pattern

class ConfigManager {
  private static instance: ConfigManager | null = null;
  private config: Map<string, string>;

  private constructor() {
    this.config = new Map();
  }

  static getInstance(): ConfigManager {
    if (!ConfigManager.instance) {
      ConfigManager.instance = new ConfigManager();
    }
    return ConfigManager.instance;
  }

  set(key: string, value: string): void {
    this.config.set(key, value);
  }

  get(key: string): string | undefined {
    return this.config.get(key);
  }
}

// Both variables reference the same instance
const config1 = ConfigManager.getInstance();
config1.set('apiUrl', 'https://api.example.com');

const config2 = ConfigManager.getInstance();
console.log(config1 === config2);
console.log(config2.get('apiUrl'));

config2.set('port', '3000');
console.log(config1.get('port'));

The private constructor prevents 'new ConfigManager()'. Both getInstance() calls return the same instance, so changes through config1 are visible through config2.

Factory Pattern

// Product interface
interface Notification {
  type: string;
  send(message: string): string;
}

// Concrete products
class EmailNotification implements Notification {
  type = 'email';
  constructor(private recipient: string) {}
  send(message: string): string {
    return `Email to ${this.recipient}: ${message}`;
  }
}

class SMSNotification implements Notification {
  type = 'sms';
  constructor(private phone: string) {}
  send(message: string): string {
    return `SMS to ${this.phone}: ${message}`;
  }
}

class PushNotification implements Notification {
  type = 'push';
  constructor(private deviceId: string) {}
  send(message: string): string {
    return `Push to ${this.deviceId}: ${message}`;
  }
}

// Factory function
function createNotification(type: 'email' | 'sms' | 'push', target: string): Notification {
  switch (type) {
    case 'email': return new EmailNotification(target);
    case 'sms': return new SMSNotification(target);
    case 'push': return new PushNotification(target);
  }
}

const notifications = [
  createNotification('email', 'alice@test.com'),
  createNotification('sms', '+1234567890'),
  createNotification('push', 'device-abc')
];

notifications.forEach(n => console.log(n.send('Hello!')));

The factory function encapsulates object creation logic. Consumers work with the Notification interface without knowing the concrete class. Adding new notification types only requires updating the factory.

Observer Pattern with Generics

// Generic typed EventEmitter
class EventEmitter<Events extends Record<string, any>> {
  private listeners = new Map<string, Function[]>();

  on<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): void {
    const handlers = this.listeners.get(event as string) || [];
    handlers.push(handler);
    this.listeners.set(event as string, handlers);
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    const handlers = this.listeners.get(event as string) || [];
    handlers.forEach(handler => handler(data));
  }

  off<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): void {
    const handlers = this.listeners.get(event as string) || [];
    this.listeners.set(event as string, handlers.filter(h => h !== handler));
  }
}

// Type-safe event definitions
interface AppEvents {
  userLogin: { userId: string; timestamp: number };
  pageView: { path: string };
  error: { message: string; code: number };
}

const emitter = new EventEmitter<AppEvents>();

emitter.on('userLogin', (data) => {
  console.log(`User ${data.userId} logged in`);
});

emitter.on('pageView', (data) => {
  console.log(`Page viewed: ${data.path}`);
});

emitter.on('error', (data) => {
  console.log(`Error ${data.code}: ${data.message}`);
});

emitter.emit('userLogin', { userId: 'alice', timestamp: Date.now() });
emitter.emit('pageView', { path: '/dashboard' });
emitter.emit('error', { message: 'Not found', code: 404 });

Generics ensure that event names and their data types are linked. Emitting 'userLogin' requires data matching { userId: string; timestamp: number }. Mismatches are caught at compile time.

Key points

Concepts covered

Singleton, Factory, Observer, Strategy, Builder, design patterns, TypeScript patterns