TypeScript

TypeScript Design Patterns Every Developer Should Know

Implementing Singleton, Factory, Observer, and Decorator patterns in TypeScript with real-world examples, complete type safety, and best practices.

Design Patterns 10 min read
Patterns Covered
  • Singleton: Managing global state with type safety
  • Factory: Creating objects with flexible initialization
  • Observer: Event-driven architectures with strong typing
  • Decorator: Enhancing functionality without modification
  • Builder: Constructing complex objects step-by-step

Why Design Patterns Matter in TypeScript

Design patterns are reusable solutions to common software design problems. TypeScript's type system makes these patterns even more powerful by catching errors at compile time and providing excellent IDE support.

This article covers five essential patterns I use regularly in production TypeScript applications. Each pattern includes a practical real-world example, complete implementation, and discussion of trade-offs.

1. Singleton Pattern

The Singleton pattern ensures a class has only one instance and provides global access to it. This is useful for managing shared resources like database connections, configuration managers, or logging systems.

Basic Implementation

// DatabaseConnection.ts
class DatabaseConnection {
  private static instance: DatabaseConnection;
  private connection: any;

  // Private constructor prevents direct instantiation
  private constructor() {
    this.connection = this.initializeConnection();
  }

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

  private initializeConnection() {
    console.log('Initializing database connection...');
    return { connected: true };
  }

  public query(sql: string) {
    console.log(`Executing: ${sql}`);
    return this.connection;
  }
}

// Usage
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();

console.log(db1 === db2); // true - same instance

Thread-Safe Singleton with Generics

// Singleton.ts
export abstract class Singleton<T> {
  private static instances = new Map<any, any>();

  protected constructor() {
    const constructor = this.constructor;
    if (Singleton.instances.has(constructor)) {
      throw new Error('Singleton instance already exists!');
    }
    Singleton.instances.set(constructor, this);
  }

  public static getInstance<T>(this: new () => T): T {
    if (!Singleton.instances.has(this)) {
      Singleton.instances.set(this, new this());
    }
    return Singleton.instances.get(this);
  }
}

// Implementation
class ConfigManager extends Singleton<ConfigManager> {
  private config: Record<string, any> = {};

  private constructor() {
    super();
    this.loadConfig();
  }

  private loadConfig() {
    this.config = {
      apiUrl: process.env.API_URL,
      apiKey: process.env.API_KEY,
    };
  }

  public get(key: string): any {
    return this.config[key];
  }

  public set(key: string, value: any): void {
    this.config[key] = value;
  }
}

// Usage
const config = ConfigManager.getInstance();
console.log(config.get('apiUrl'));

When to Use Singleton

Use Singleton for:
  • Database connection pools
  • Application configuration managers
  • Logging services
  • Cache managers
Avoid Singleton for:
  • Testing (makes mocking difficult)
  • When you need multiple instances
  • Stateless utilities (use static methods instead)

2. Factory Pattern

The Factory pattern provides an interface for creating objects without specifying their exact classes. This is particularly useful when the creation logic is complex or when you want to decouple object creation from usage.

Simple Factory

// types.ts
interface Notification {
  send(message: string): void;
}

// EmailNotification.ts
class EmailNotification implements Notification {
  constructor(private recipient: string) {}

  send(message: string): void {
    console.log(`Sending email to ${this.recipient}: ${message}`);
  }
}

// SMSNotification.ts
class SMSNotification implements Notification {
  constructor(private phoneNumber: string) {}

  send(message: string): void {
    console.log(`Sending SMS to ${this.phoneNumber}: ${message}`);
  }
}

// PushNotification.ts
class PushNotification implements Notification {
  constructor(private deviceId: string) {}

  send(message: string): void {
    console.log(`Sending push to device ${this.deviceId}: ${message}`);
  }
}

// NotificationFactory.ts
type NotificationType = 'email' | 'sms' | 'push';

class NotificationFactory {
  static create(
    type: NotificationType,
    recipient: string
  ): Notification {
    switch (type) {
      case 'email':
        return new EmailNotification(recipient);
      case 'sms':
        return new SMSNotification(recipient);
      case 'push':
        return new PushNotification(recipient);
      default:
        throw new Error(`Unknown notification type: ${type}`);
    }
  }
}

// Usage
const notification = NotificationFactory.create('email', 'user@example.com');
notification.send('Welcome to our platform!');

Abstract Factory Pattern

// UIFactory.ts
interface Button {
  render(): string;
  onClick(handler: () => void): void;
}

interface Input {
  render(): string;
  getValue(): string;
}

// Concrete implementations
class MaterialButton implements Button {
  render(): string {
    return '<button class="material-btn">Click Me</button>';
  }
  onClick(handler: () => void): void {
    console.log('Material button clicked');
    handler();
  }
}

class MaterialInput implements Input {
  private value = '';
  render(): string {
    return '<input class="material-input" />';
  }
  getValue(): string {
    return this.value;
  }
}

class BootstrapButton implements Button {
  render(): string {
    return '<button class="btn btn-primary">Click Me</button>';
  }
  onClick(handler: () => void): void {
    console.log('Bootstrap button clicked');
    handler();
  }
}

class BootstrapInput implements Input {
  private value = '';
  render(): string {
    return '<input class="form-control" />';
  }
  getValue(): string {
    return this.value;
  }
}

// Abstract factory
interface UIFactory {
  createButton(): Button;
  createInput(): Input;
}

class MaterialUIFactory implements UIFactory {
  createButton(): Button {
    return new MaterialButton();
  }
  createInput(): Input {
    return new MaterialInput();
  }
}

class BootstrapUIFactory implements UIFactory {
  createButton(): Button {
    return new BootstrapButton();
  }
  createInput(): Input {
    return new BootstrapInput();
  }
}

// Usage
function renderForm(factory: UIFactory) {
  const button = factory.createButton();
  const input = factory.createInput();

  console.log(input.render());
  console.log(button.render());
}

// Switch themes by changing factory
const materialFactory = new MaterialUIFactory();
renderForm(materialFactory);

const bootstrapFactory = new BootstrapUIFactory();
renderForm(bootstrapFactory);

3. Observer Pattern

The Observer pattern defines a one-to-many dependency between objects. When one object changes state, all its dependents are notified automatically. This is the foundation of event-driven programming.

Type-Safe Event Emitter

// EventEmitter.ts
type EventHandler<T = any> = (data: T) => void;

interface EventMap {
  [event: string]: any;
}

class TypedEventEmitter<T extends EventMap> {
  private listeners: {
    [K in keyof T]?: EventHandler<T[K]>[];
  } = {};

  on<K extends keyof T>(event: K, handler: EventHandler<T[K]>): () => void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(handler);

    // Return unsubscribe function
    return () => this.off(event, handler);
  }

  off<K extends keyof T>(event: K, handler: EventHandler<T[K]>): void {
    const handlers = this.listeners[event];
    if (handlers) {
      this.listeners[event] = handlers.filter((h) => h !== handler) as any;
    }
  }

  emit<K extends keyof T>(event: K, data: T[K]): void {
    const handlers = this.listeners[event];
    if (handlers) {
      handlers.forEach((handler) => handler(data));
    }
  }

  once<K extends keyof T>(event: K, handler: EventHandler<T[K]>): void {
    const onceHandler: EventHandler<T[K]> = (data) => {
      handler(data);
      this.off(event, onceHandler);
    };
    this.on(event, onceHandler);
  }
}

// Usage with typed events
interface UserEvents {
  login: { userId: string; timestamp: number };
  logout: { userId: string };
  update: { userId: string; field: string; value: any };
}

class UserManager extends TypedEventEmitter<UserEvents> {
  login(userId: string) {
    // Perform login logic
    this.emit('login', { userId, timestamp: Date.now() });
  }

  logout(userId: string) {
    // Perform logout logic
    this.emit('logout', { userId });
  }
}

const userManager = new UserManager();

// Type-safe event handling
userManager.on('login', (data) => {
  console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});

userManager.on('logout', (data) => {
  console.log(`User ${data.userId} logged out`);
});

// Unsubscribe pattern
const unsubscribe = userManager.on('login', (data) => {
  console.log('Login event received');
});

// Later...
unsubscribe(); // Stop listening

Observable State Management

// Observable.ts
type Subscriber<T> = (value: T) => void;

class Observable<T> {
  private value: T;
  private subscribers = new Set<Subscriber<T>>();

  constructor(initialValue: T) {
    this.value = initialValue;
  }

  getValue(): T {
    return this.value;
  }

  setValue(newValue: T): void {
    if (this.value !== newValue) {
      this.value = newValue;
      this.notify();
    }
  }

  subscribe(subscriber: Subscriber<T>): () => void {
    this.subscribers.add(subscriber);
    subscriber(this.value); // Immediately call with current value

    return () => this.subscribers.delete(subscriber);
  }

  private notify(): void {
    this.subscribers.forEach((subscriber) => subscriber(this.value));
  }
}

// Usage
const count = new Observable(0);

const unsubscribe = count.subscribe((value) => {
  console.log(`Count changed to: ${value}`);
});

count.setValue(1); // Logs: "Count changed to: 1"
count.setValue(2); // Logs: "Count changed to: 2"

unsubscribe();
count.setValue(3); // Nothing logged

4. Decorator Pattern

Decorators attach additional responsibilities to objects dynamically. TypeScript's decorator syntax makes this pattern particularly elegant.

Method Decorators

// decorators.ts
function Logger(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;

  descriptor.value = function (...args: any[]) {
    console.log(`Calling ${propertyKey} with:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`Result:`, result);
    return result;
  };

  return descriptor;
}

function Memoize(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;
  const cache = new Map<string, any>();

  descriptor.value = function (...args: any[]) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      console.log(`Cache hit for ${propertyKey}`);
      return cache.get(key);
    }

    const result = originalMethod.apply(this, args);
    cache.set(key, result);
    return result;
  };

  return descriptor;
}

function Retry(maxAttempts: number = 3) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;

    descriptor.value = async function (...args: any[]) {
      let lastError: any;

      for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
          return await originalMethod.apply(this, args);
        } catch (error) {
          lastError = error;
          console.log(`Attempt ${attempt} failed, retrying...`);
          await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
        }
      }

      throw lastError;
    };

    return descriptor;
  };
}

// Usage
class Calculator {
  @Logger
  add(a: number, b: number): number {
    return a + b;
  }

  @Memoize
  fibonacci(n: number): number {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }
}

class APIClient {
  @Retry(3)
  async fetchData(url: string): Promise<any> {
    const response = await fetch(url);
    if (!response.ok) throw new Error('Request failed');
    return response.json();
  }
}

const calc = new Calculator();
calc.add(2, 3); // Logs method call and result
calc.fibonacci(10); // Uses memoization for performance

5. Builder Pattern

The Builder pattern constructs complex objects step by step, providing a fluent interface for object creation.

// QueryBuilder.ts
interface QueryConfig {
  table: string;
  fields?: string[];
  where?: Record<string, any>;
  orderBy?: { field: string; direction: 'ASC' | 'DESC' };
  limit?: number;
  offset?: number;
}

class QueryBuilder {
  private config: QueryConfig;

  constructor(table: string) {
    this.config = { table };
  }

  select(...fields: string[]): this {
    this.config.fields = fields;
    return this;
  }

  where(conditions: Record<string, any>): this {
    this.config.where = conditions;
    return this;
  }

  orderBy(field: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
    this.config.orderBy = { field, direction };
    return this;
  }

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

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

  build(): string {
    const { table, fields, where, orderBy, limit, offset } = this.config;

    let query = `SELECT ${fields?.join(', ') || '*'} FROM ${table}`;

    if (where) {
      const conditions = Object.entries(where)
        .map(([key, value]) => `${key} = '${value}'`)
        .join(' AND ');
      query += ` WHERE ${conditions}`;
    }

    if (orderBy) {
      query += ` ORDER BY ${orderBy.field} ${orderBy.direction}`;
    }

    if (limit) {
      query += ` LIMIT ${limit}`;
    }

    if (offset) {
      query += ` OFFSET ${offset}`;
    }

    return query;
  }
}

// Usage
const query = new QueryBuilder('users')
  .select('id', 'name', 'email')
  .where({ status: 'active' })
  .orderBy('created_at', 'DESC')
  .limit(10)
  .build();

console.log(query);
// SELECT id, name, email FROM users WHERE status = 'active' ORDER BY created_at DESC LIMIT 10

Choosing the Right Pattern

Decision Guide
  • Singleton: When you need exactly one instance (logger, config, connection pool)
  • Factory: When object creation is complex or depends on runtime conditions
  • Observer: When you need reactive, event-driven behavior
  • Decorator: When you want to add functionality without modifying classes
  • Builder: When constructing objects requires many optional parameters

Key Takeaways

Type safety is crucial. TypeScript's type system catches pattern implementation errors at compile time.

Don't overuse patterns. Apply patterns when they solve real problems, not because they're trendy.

Favor composition over inheritance. Many patterns work better with composition in modern TypeScript.

Consider testability. Some patterns (like Singleton) can make testing harder. Plan accordingly.