The Decorator pattern attaches additional responsibilities to an object dynamically. It provides a flexible alternative to subclassing for extending functionality. In TypeScript, decorators shine for cross-cutting concerns like logging, caching, validation, and retry logic.

The Core Idea

Decorators wrap an object with the same interface, adding behavior before or after delegating to the wrapped object:

interface HttpClient {
  get<T>(url: string): Promise<T>;
  post<T>(url: string, data: unknown): Promise<T>;
}

// Base implementation
class FetchHttpClient implements HttpClient {
  async get<T>(url: string): Promise<T> {
    const response = await fetch(url);
    return response.json();
  }

  async post<T>(url: string, data: unknown): Promise<T> {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    return response.json();
  }
}

Logging Decorator

class LoggingHttpClient implements HttpClient {
  constructor(
    private wrapped: HttpClient,
    private logger: Logger,
  ) {}

  async get<T>(url: string): Promise<T> {
    this.logger.info(`GET ${url}`);
    const start = performance.now();
    try {
      const result = await this.wrapped.get<T>(url);
      this.logger.info(`GET ${url}${(performance.now() - start).toFixed(0)}ms`);
      return result;
    } catch (error) {
      this.logger.error(`GET ${url} failed`, error);
      throw error;
    }
  }

  async post<T>(url: string, data: unknown): Promise<T> {
    this.logger.info(`POST ${url}`);
    const start = performance.now();
    try {
      const result = await this.wrapped.post<T>(url, data);
      this.logger.info(`POST ${url}${(performance.now() - start).toFixed(0)}ms`);
      return result;
    } catch (error) {
      this.logger.error(`POST ${url} failed`, error);
      throw error;
    }
  }
}

Caching Decorator

class CachingHttpClient implements HttpClient {
  private cache = new Map<string, { data: unknown; expires: number }>();

  constructor(
    private wrapped: HttpClient,
    private ttlMs: number = 60_000,
  ) {}

  async get<T>(url: string): Promise<T> {
    const cached = this.cache.get(url);
    if (cached && cached.expires > Date.now()) {
      return cached.data as T;
    }

    const result = await this.wrapped.get<T>(url);
    this.cache.set(url, { data: result, expires: Date.now() + this.ttlMs });
    return result;
  }

  async post<T>(url: string, data: unknown): Promise<T> {
    // POST requests invalidate cache and don't get cached
    this.cache.delete(url);
    return this.wrapped.post<T>(url, data);
  }
}

Retry Decorator

class RetryHttpClient implements HttpClient {
  constructor(
    private wrapped: HttpClient,
    private maxRetries: number = 3,
    private baseDelayMs: number = 1000,
  ) {}

  async get<T>(url: string): Promise<T> {
    return this.withRetry(() => this.wrapped.get<T>(url));
  }

  async post<T>(url: string, data: unknown): Promise<T> {
    return this.withRetry(() => this.wrapped.post<T>(url, data));
  }

  private async withRetry<T>(fn: () => Promise<T>): Promise<T> {
    let lastError: Error;
    
    for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
      try {
        return await fn();
      } catch (error) {
        lastError = error as Error;
        if (attempt < this.maxRetries) {
          const delay = this.baseDelayMs * Math.pow(2, attempt);
          await new Promise(resolve => setTimeout(resolve, delay));
        }
      }
    }
    
    throw lastError!;
  }
}

Composing Decorators

The magic happens when you stack decorators — each adds its behavior without knowing about the others:

// Build a fully-featured HTTP client from simple pieces
const httpClient: HttpClient = new RetryHttpClient(
  new CachingHttpClient(
    new LoggingHttpClient(
      new FetchHttpClient(),
      logger,
    ),
    60_000,  // 1 minute cache
  ),
  3,     // 3 retries
  1000,  // 1s base delay
);

// Usage is identical to the base client
const users = await httpClient.get<User[]>('/api/users');
// Internally: retry → check cache → log → fetch

Builder for Complex Decoration

class HttpClientBuilder {
  private client: HttpClient = new FetchHttpClient();

  withLogging(logger: Logger): this {
    this.client = new LoggingHttpClient(this.client, logger);
    return this;
  }

  withCaching(ttlMs: number = 60_000): this {
    this.client = new CachingHttpClient(this.client, ttlMs);
    return this;
  }

  withRetry(maxRetries: number = 3): this {
    this.client = new RetryHttpClient(this.client, maxRetries);
    return this;
  }

  build(): HttpClient {
    return this.client;
  }
}

// Fluent API
const client = new HttpClientBuilder()
  .withLogging(logger)
  .withCaching(30_000)
  .withRetry(3)
  .build();

The Decorator pattern lets you compose complex behavior from simple, focused pieces. Each decorator does one thing, making it easy to test, reuse, and recombine.

“Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.” — Gang of Four