TypeScript Design Patterns: A Practical Guide for 2026
Author
ZTABS Team
Date Published
Design patterns exist to solve recurring structural problems in code. In TypeScript, the type system makes many patterns more expressive and safer than in plain JavaScript — generic constraints enforce contracts, discriminated unions replace brittle conditionals, and interfaces formalize the boundaries that patterns rely on.
This guide covers five patterns that consistently prove valuable in production TypeScript applications. Each section explains the problem the pattern solves, shows a complete implementation, and discusses when to reach for it versus when it adds unnecessary complexity.
The Factory Pattern
The Factory pattern encapsulates object creation logic behind a unified interface. Instead of scattering new calls and conditional logic across your codebase, you centralize creation decisions in one place.
The Problem
Consider a notification system that sends messages through different channels. Without a factory, every component that creates notifications must know about every channel type.
// Without a factory — creation logic scattered everywhere
function sendAlert(channel: string, message: string) {
if (channel === "email") {
const sender = new EmailNotification(message, config.smtp);
sender.send();
} else if (channel === "slack") {
const sender = new SlackNotification(message, config.slackWebhook);
sender.send();
} else if (channel === "sms") {
const sender = new SmsNotification(message, config.twilioSid);
sender.send();
}
// Adding a new channel means finding and updating every if/else chain
}
The Solution
Define a common interface and a factory function that maps channel types to concrete implementations.
interface Notification {
send(recipient: string): Promise<{ success: boolean; messageId: string }>;
retry(attempts: number): Promise<void>;
}
interface NotificationConfig {
email: { smtpHost: string; smtpPort: number; from: string };
slack: { webhookUrl: string; channel: string };
sms: { accountSid: string; authToken: string; from: string };
}
type NotificationChannel = keyof NotificationConfig;
class EmailNotification implements Notification {
constructor(
private message: string,
private config: NotificationConfig["email"]
) {}
async send(recipient: string) {
const messageId = crypto.randomUUID();
await sendEmail({
to: recipient,
from: this.config.from,
subject: "Notification",
body: this.message,
host: this.config.smtpHost,
port: this.config.smtpPort,
});
return { success: true, messageId };
}
async retry(attempts: number) {
for (let i = 0; i < attempts; i++) {
try {
await this.send("retry-queue");
return;
} catch {
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
}
}
}
}
class SlackNotification implements Notification {
constructor(
private message: string,
private config: NotificationConfig["slack"]
) {}
async send(_recipient: string) {
const response = await fetch(this.config.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: this.message, channel: this.config.channel }),
});
return { success: response.ok, messageId: crypto.randomUUID() };
}
async retry(attempts: number) {
for (let i = 0; i < attempts; i++) {
try {
await this.send("retry-queue");
return;
} catch {
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
}
}
}
}
function createNotification<T extends NotificationChannel>(
channel: T,
message: string,
config: NotificationConfig[T]
): Notification {
const factories: Record<NotificationChannel, () => Notification> = {
email: () => new EmailNotification(message, config as NotificationConfig["email"]),
slack: () => new SlackNotification(message, config as NotificationConfig["slack"]),
sms: () => new SmsNotification(message, config as NotificationConfig["sms"]),
};
const factory = factories[channel];
if (!factory) throw new Error(`Unknown channel: ${channel}`);
return factory();
}
// Usage — clean, centralized creation
const notification = createNotification("slack", "Deploy complete", slackConfig);
await notification.send("#engineering");
Adding a new channel means implementing the Notification interface and adding one line to the factory map. No consumer code changes.
When to Use the Factory Pattern
Use a factory when you have multiple implementations of a common interface and the creation logic involves configuration, conditional selection, or dependency injection. Skip it when you have only one or two straightforward constructors — a factory around a single class is just indirection.
The Observer Pattern
The Observer pattern defines a one-to-many dependency between objects. When the subject changes state, all registered observers are notified automatically. This is the backbone of event-driven architectures.
A Type-Safe Event Emitter
TypeScript generics let you build an event system where every event name maps to a specific payload type — no any, no runtime type errors.
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string; sessionDuration: number };
"order:created": { orderId: string; total: number; items: number };
"order:shipped": { orderId: string; trackingNumber: string };
};
type EventHandler<T> = (payload: T) => void | Promise<void>;
class TypedEventEmitter<TEvents extends Record<string, unknown>> {
private listeners = new Map<keyof TEvents, Set<EventHandler<unknown>>>();
on<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
const handlers = this.listeners.get(event)!;
handlers.add(handler as EventHandler<unknown>);
return () => {
handlers.delete(handler as EventHandler<unknown>);
};
}
async emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): Promise<void> {
const handlers = this.listeners.get(event);
if (!handlers) return;
const promises = Array.from(handlers).map((handler) =>
Promise.resolve(handler(payload))
);
await Promise.allSettled(promises);
}
removeAllListeners(event?: keyof TEvents): void {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
}
}
// Usage — fully typed events and payloads
const events = new TypedEventEmitter<EventMap>();
const unsubscribe = events.on("order:created", async (payload) => {
// TypeScript knows payload is { orderId: string; total: number; items: number }
await sendConfirmationEmail(payload.orderId);
await updateAnalytics("revenue", payload.total);
});
await events.emit("order:created", {
orderId: "ord_123",
total: 99.99,
items: 3,
});
unsubscribe();
The on method returns an unsubscribe function — a pattern borrowed from modern state management libraries that is far cleaner than a separate off method.
When to Use the Observer Pattern
Use it when multiple independent systems need to react to state changes without tight coupling — analytics, logging, cache invalidation, webhook dispatching. Avoid it when the flow of events becomes so complex that debugging requires tracing through dozens of handlers. At that point, consider an explicit state machine or event sourcing pattern instead.
The Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The client selects a strategy at runtime without knowing the implementation details.
A Pricing Engine
E-commerce pricing is a perfect use case. Different customer segments get different pricing logic, and the rules change frequently.
interface PricingStrategy {
calculate(basePrice: number, quantity: number): PricingResult;
name: string;
}
interface PricingResult {
unitPrice: number;
totalPrice: number;
discount: number;
appliedRule: string;
}
const standardPricing: PricingStrategy = {
name: "standard",
calculate(basePrice, quantity) {
const totalPrice = basePrice * quantity;
return { unitPrice: basePrice, totalPrice, discount: 0, appliedRule: "No discount" };
},
};
const volumePricing: PricingStrategy = {
name: "volume",
calculate(basePrice, quantity) {
const tierRate =
quantity >= 100 ? 0.25 : quantity >= 50 ? 0.15 : quantity >= 20 ? 0.1 : 0;
const unitPrice = basePrice * (1 - tierRate);
const totalPrice = unitPrice * quantity;
const discount = (basePrice - unitPrice) * quantity;
return {
unitPrice,
totalPrice,
discount,
appliedRule: `Volume tier: ${tierRate * 100}% off`,
};
},
};
const subscriberPricing: PricingStrategy = {
name: "subscriber",
calculate(basePrice, quantity) {
const subscriberDiscount = 0.2;
const unitPrice = basePrice * (1 - subscriberDiscount);
const totalPrice = unitPrice * quantity;
const discount = (basePrice - unitPrice) * quantity;
return {
unitPrice,
totalPrice,
discount,
appliedRule: "Subscriber: 20% off",
};
},
};
class PricingEngine {
private strategy: PricingStrategy;
constructor(strategy: PricingStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: PricingStrategy) {
this.strategy = strategy;
}
calculatePrice(basePrice: number, quantity: number): PricingResult {
return this.strategy.calculate(basePrice, quantity);
}
}
// Usage — swap strategies at runtime
const engine = new PricingEngine(standardPricing);
function getPricingForCustomer(customerType: string): PricingStrategy {
const strategies: Record<string, PricingStrategy> = {
standard: standardPricing,
wholesale: volumePricing,
subscriber: subscriberPricing,
};
return strategies[customerType] ?? standardPricing;
}
engine.setStrategy(getPricingForCustomer("wholesale"));
const result = engine.calculatePrice(49.99, 75);
// { unitPrice: 42.49, totalPrice: 3186.75, discount: 562.43, appliedRule: "Volume tier: 15% off" }
Adding a new pricing tier means creating a new object that satisfies the PricingStrategy interface. The engine, the checkout flow, and the cart display code never change.
When to Use the Strategy Pattern
Use it when an algorithm varies by context and the variation is a first-class concept in your domain — pricing rules, sorting algorithms, validation pipelines, authentication methods. Avoid it when the variation is a simple boolean flag. A two-branch if statement is clearer than a strategy hierarchy.
The Decorator Pattern
The Decorator pattern dynamically adds behavior to an object without modifying its structure. In TypeScript, this works elegantly with wrapper functions that preserve the original interface.
A Composable HTTP Client
Rather than building a monolithic HTTP client with every feature baked in, use decorators to compose capabilities.
interface HttpClient {
request<T>(url: string, options?: RequestInit): Promise<T>;
}
function createBaseClient(): HttpClient {
return {
async request<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
},
};
}
function withLogging(client: HttpClient): HttpClient {
return {
async request<T>(url: string, options?: RequestInit): Promise<T> {
const start = performance.now();
console.log(`[HTTP] ${options?.method ?? "GET"} ${url}`);
try {
const result = await client.request<T>(url, options);
console.log(`[HTTP] ${url} completed in ${(performance.now() - start).toFixed(0)}ms`);
return result;
} catch (error) {
console.error(`[HTTP] ${url} failed after ${(performance.now() - start).toFixed(0)}ms`);
throw error;
}
},
};
}
function withRetry(client: HttpClient, maxRetries = 3): HttpClient {
return {
async request<T>(url: string, options?: RequestInit): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await client.request<T>(url, options);
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
}
}
}
throw lastError;
},
};
}
function withAuth(client: HttpClient, getToken: () => string): HttpClient {
return {
async request<T>(url: string, options?: RequestInit): Promise<T> {
const authOptions: RequestInit = {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${getToken()}`,
},
};
return client.request<T>(url, authOptions);
},
};
}
function withCache(client: HttpClient, ttlMs = 60_000): HttpClient {
const cache = new Map<string, { data: unknown; expires: number }>();
return {
async request<T>(url: string, options?: RequestInit): Promise<T> {
const method = options?.method?.toUpperCase() ?? "GET";
if (method !== "GET") return client.request<T>(url, options);
const cached = cache.get(url);
if (cached && cached.expires > Date.now()) return cached.data as T;
const result = await client.request<T>(url, options);
cache.set(url, { data: result, expires: Date.now() + ttlMs });
return result;
},
};
}
// Compose decorators — order matters
const apiClient = withCache(
withRetry(
withLogging(
withAuth(createBaseClient(), () => getAccessToken())
),
3
),
30_000
);
// Every request now has auth, logging, retry, and caching — transparently
const users = await apiClient.request<User[]>("/api/users");
Each decorator adds exactly one capability. You can compose them in any order, include or exclude any combination, and test each in isolation. This is the open-closed principle in practice — open for extension, closed for modification.
When to Use the Decorator Pattern
Use it when you need to add cross-cutting concerns (logging, caching, auth, retry, metrics) to an existing interface without modifying it. It is also excellent for middleware pipelines. Avoid deep decorator chains that become hard to debug — if you are stacking more than four or five layers, consider a middleware array pattern instead.
The Builder Pattern
The Builder pattern constructs complex objects step by step. In TypeScript, it shines when an object has many optional properties and you want to enforce valid combinations through the type system.
A Type-Safe Query Builder
interface QueryConfig {
table: string;
fields: string[];
conditions: Array<{ field: string; operator: string; value: unknown }>;
orderBy: Array<{ field: string; direction: "ASC" | "DESC" }>;
limit?: number;
offset?: number;
joins: Array<{ table: string; on: string; type: "INNER" | "LEFT" | "RIGHT" }>;
}
class QueryBuilder {
private config: QueryConfig;
constructor(table: string) {
this.config = {
table,
fields: ["*"],
conditions: [],
orderBy: [],
joins: [],
};
}
select(...fields: string[]): this {
this.config.fields = fields;
return this;
}
where(field: string, operator: string, value: unknown): this {
this.config.conditions.push({ field, operator, value });
return this;
}
join(table: string, on: string, type: "INNER" | "LEFT" | "RIGHT" = "INNER"): this {
this.config.joins.push({ table, on, type });
return this;
}
orderBy(field: string, direction: "ASC" | "DESC" = "ASC"): this {
this.config.orderBy.push({ field, direction });
return this;
}
limit(n: number): this {
this.config.limit = n;
return this;
}
offset(n: number): this {
this.config.offset = n;
return this;
}
build(): { sql: string; params: unknown[] } {
const params: unknown[] = [];
let paramIndex = 1;
let sql = `SELECT ${this.config.fields.join(", ")} FROM ${this.config.table}`;
for (const join of this.config.joins) {
sql += ` ${join.type} JOIN ${join.table} ON ${join.on}`;
}
if (this.config.conditions.length > 0) {
const clauses = this.config.conditions.map((c) => {
params.push(c.value);
return `${c.field} ${c.operator} $${paramIndex++}`;
});
sql += ` WHERE ${clauses.join(" AND ")}`;
}
if (this.config.orderBy.length > 0) {
const orders = this.config.orderBy.map((o) => `${o.field} ${o.direction}`);
sql += ` ORDER BY ${orders.join(", ")}`;
}
if (this.config.limit !== undefined) {
sql += ` LIMIT ${this.config.limit}`;
}
if (this.config.offset !== undefined) {
sql += ` OFFSET ${this.config.offset}`;
}
return { sql, params };
}
}
// Usage — readable, composable query construction
const { sql, params } = new QueryBuilder("orders")
.select("orders.id", "orders.total", "users.name", "users.email")
.join("users", "users.id = orders.user_id", "INNER")
.where("orders.status", "=", "completed")
.where("orders.total", ">", 100)
.orderBy("orders.created_at", "DESC")
.limit(20)
.offset(40)
.build();
// sql: SELECT orders.id, orders.total, users.name, users.email FROM orders
// INNER JOIN users ON users.id = orders.user_id
// WHERE orders.status = $1 AND orders.total > $2
// ORDER BY orders.created_at DESC LIMIT 20 OFFSET 40
// params: ["completed", 100]
The builder provides a fluent, readable API for constructing complex queries. Each method returns this, enabling method chaining. The build method produces the final parameterized query — safe from SQL injection.
When to Use the Builder Pattern
Use it for complex configuration objects — database queries, HTTP request builders, form schemas, test fixtures. The builder pattern excels when the construction process has multiple optional steps and the final object should be immutable once built. Avoid it for simple value objects where a constructor or object literal suffices.
Choosing the Right Pattern
Selecting a design pattern should be a pragmatic decision, not an exercise in software architecture for its own sake. Here is a decision framework:
| Problem | Pattern | Why | |---------|---------|-----| | Multiple implementations of the same interface with conditional creation | Factory | Centralizes creation logic, easy to extend | | Multiple systems need to react to state changes independently | Observer | Decouples producers from consumers | | An algorithm varies by context and the variation is a domain concept | Strategy | Swappable behavior without modifying consuming code | | Cross-cutting concerns need to wrap existing behavior | Decorator | Composable, respects open-closed principle | | Complex object with many optional properties and validation rules | Builder | Readable, step-by-step construction with type safety |
The strongest signal that a pattern is needed is when you find yourself duplicating conditional logic across multiple call sites. The factory centralizes that logic. The strategy makes the variation explicit. The decorator adds behavior without touching the original. The observer decouples the trigger from the reaction. The builder tames construction complexity.
From Patterns to Production
Design patterns are not goals in themselves — they are tools that help you write code that is easier to extend, test, and maintain. The patterns in this guide cover the most common structural problems in TypeScript applications: creating objects, reacting to changes, swapping algorithms, adding behavior, and constructing complex configurations.
The key is knowing when each pattern pays for its complexity. A factory that wraps a single constructor is over-engineering. A strategy pattern for a two-branch condition is ceremony. But a factory that manages twelve notification channels or a decorator stack that composes five cross-cutting concerns — those are patterns earning their keep.
If you are building a TypeScript web application and need help designing clean, scalable architecture — or refactoring an existing codebase to be more maintainable — talk to our team. We help engineering teams adopt the right patterns for their specific problems, not patterns for the sake of patterns.
Write clear code. Introduce patterns when the code tells you it needs them.
Explore Related Solutions
Need Help Building Your Project?
From web apps and mobile apps to AI solutions and SaaS platforms — we ship production software for 300+ clients.
Related Articles
API Security Best Practices: A Developer Guide for 2026
A practical guide to securing APIs in production. Covers OAuth 2.0, JWT handling, rate limiting, input validation, CORS configuration, API key management, and security headers with real code examples.
12 min readDatabase Scaling Strategies: From Single Server to Global Scale
A practical guide to database scaling strategies for growing applications. Covers vertical and horizontal scaling, read replicas, sharding, connection pooling, caching layers, and partition strategies with real SQL examples.
8 min readGraphQL vs REST API: When to Use Each in 2026
A practical comparison of GraphQL and REST for modern applications. Covers over-fetching, the N+1 problem, schema stitching, performance tradeoffs, and clear guidance on when each approach wins.