Decorator Pattern in Practice
Learn to use the Decorator pattern to add responsibilities to objects dynamically, with TypeScript examples for logging, caching, and retry logic.
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