Introduction
Design patterns are reusable solutions to common problems in software design. The Gang of Four (GoF) patterns, cataloged in 1994 by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, remain the foundation of object-oriented software architecture. While the original patterns were described in C++ and Smalltalk, JavaScript's dynamic nature—prototypal inheritance, first-class functions, closures—makes many patterns simpler and more elegant than their classical counterparts.
This guide covers the four most essential pattern categories—Creational, Structural, and Behavioral—with practical JavaScript implementations. Each pattern includes real-world use cases, TypeScript type annotations, and guidance on when to use (and when to avoid) each pattern.
Understanding Design Patterns: Core Concepts
Why Patterns Matter
Design patterns serve three purposes:
- Communication: Saying "use an Observer" conveys more information than describing the entire implementation
- Proven solutions: Patterns have been refined over decades across thousands of codebases
- Architecture: Patterns provide structure that makes codebases maintainable and extensible
JavaScript's unique features make pattern implementation particularly interesting:
- Closures replace private variables
- First-class functions simplify Strategy and Command patterns
- Prototypal inheritance offers alternatives to classical inheritance
- Modules provide namespace isolation
- Proxy enables powerful metaprogramming
Pattern Categories
The GoF organized 23 patterns into three categories:
- Creational: How objects are instantiated (Singleton, Factory, Builder, Prototype)
- Structural: How objects are composed (Adapter, Decorator, Facade, Proxy)
- Behavioral: How objects communicate (Observer, Strategy, Command, Iterator)
Architecture and Design Patterns
Singleton Pattern
The Singleton ensures a class has only one instance and provides a global point of access. In JavaScript, closures provide a natural implementation:
// Classic Singleton with closure
class DatabaseConnection {
private static instance: DatabaseConnection | null = null;
private connectionString: string;
private constructor(connectionString: string) {
this.connectionString = connectionString;
}
static getInstance(connectionString?: string): DatabaseConnection {
if (!DatabaseConnection.instance) {
if (!connectionString) {
throw new Error("Connection string required for first initialization");
}
DatabaseConnection.instance = new DatabaseConnection(connectionString);
}
return DatabaseConnection.instance;
}
query(sql: string): Promise<unknown[]> {
console.log(`Executing: ${sql} on ${this.connectionString}`);
return Promise.resolve([]);
}
}
// Usage
const db = DatabaseConnection.getInstance("postgres://localhost/mydb");
const db2 = DatabaseConnection.getInstance(); // Returns same instance
console.log(db === db2); // true// Module-based Singleton (more idiomatic in JavaScript)
// db.ts
let instance: DatabaseConnection | null = null;
export function getDatabase(): DatabaseConnection {
if (!instance) {
instance = new DatabaseConnection(process.env.DATABASE_URL!);
}
return instance;
}
// Usage
import { getDatabase } from "./db.ts";
const db = getDatabase();Observer Pattern
The Observer pattern defines a one-to-many dependency between objects. When one object changes state, all dependents are notified. This is the foundation of event-driven programming:
// Type-safe Observer implementation
type EventHandler<T = unknown> = (data: T) => void;
class EventEmitter<Events extends Record<string, unknown>> {
private handlers = new Map<keyof Events, Set<EventHandler>>();
on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler as EventHandler);
// Return unsubscribe function
return () => {
this.handlers.get(event)?.delete(handler as EventHandler);
};
}
emit<K extends keyof Events>(event: K, data: Events[K]): void {
this.handlers.get(event)?.forEach((handler) => handler(data));
}
once<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): () => void {
const unsubscribe = this.on(event, (data) => {
unsubscribe();
handler(data);
});
return unsubscribe;
}
}
// Usage with typed events
interface AppEvents {
userLogin: { userId: string; timestamp: number };
userLogout: { userId: string };
pageView: { path: string; referrer: string };
}
const events = new EventEmitter<AppEvents>();
const unsubscribe = events.on("userLogin", (data) => {
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
events.emit("userLogin", { userId: "123", timestamp: Date.now() });
unsubscribe(); // Clean upFactory Pattern
The Factory pattern creates objects without specifying their exact class. JavaScript's dynamic typing makes this pattern particularly natural:
// Abstract Factory for different notification channels
interface Notification {
send(message: string): Promise<void>;
}
class EmailNotification implements Notification {
constructor(private to: string) {}
async send(message: string): Promise<void> {
console.log(`Email to ${this.to}: ${message}`);
}
}
class SlackNotification implements Notification {
constructor(private channel: string) {}
async send(message: string): Promise<void> {
console.log(`Slack #${this.channel}: ${message}`);
}
}
class SMSNotification implements Notification {
constructor(private phone: string) {}
async send(message: string): Promise<void> {
console.log(`SMS to ${this.phone}: ${message}`);
}
}
// Factory
class NotificationFactory {
static create(type: string, recipient: string): Notification {
switch (type) {
case "email":
return new EmailNotification(recipient);
case "slack":
return new SlackNotification(recipient);
case "sms":
return new SMSNotification(recipient);
default:
throw new Error(`Unknown notification type: ${type}`);
}
}
}
// Usage
const notification = NotificationFactory.create("email", "user@example.com");
await notification.send("Your order has shipped!");Strategy Pattern
The Strategy pattern defines a family of algorithms and makes them interchangeable. JavaScript's first-class functions make this pattern trivially simple:
// Strategy pattern for data validation
type ValidationStrategy<T> = (value: T) => string | null;
const strategies: Record<string, ValidationStrategy<string>> = {
email: (value) => {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Invalid email";
return null;
},
phone: (value) => {
if (!/^\+?[\d\s-]{10,}$/.test(value)) return "Invalid phone number";
return null;
},
url: (value) => {
try {
new URL(value);
return null;
} catch {
return "Invalid URL";
}
},
minLength: (value) => {
if (value.length < 3) return "Too short";
return null;
},
};
// Validator using strategies
class Validator {
private rules = new Map<string, ValidationStrategy<string>>();
addRule(field: string, strategy: ValidationStrategy<string>): this {
this.rules.set(field, strategy);
return this;
}
validate(data: Record<string, string>): Map<string, string> {
const errors = new Map<string, string>();
for (const [field, strategy] of this.rules) {
const error = strategy(data[field] ?? "");
if (error) errors.set(field, error);
}
return errors;
}
}
// Usage
const validator = new Validator()
.addRule("email", strategies.email)
.addRule("phone", strategies.phone);
const errors = validator.validate({
email: "invalid",
phone: "+1234567890",
});
console.log(errors); // Map { "email" => "Invalid email" }Step-by-Step Implementation
Building a Plugin System with Observer + Factory
// Plugin architecture combining patterns
interface Plugin {
name: string;
version: string;
initialize(app: App): void;
destroy(): void;
}
class App {
private plugins = new Map<string, Plugin>();
private events = new EventEmitter<Record<string, unknown>>();
register(plugin: Plugin): void {
if (this.plugins.has(plugin.name)) {
throw new Error(`Plugin ${plugin.name} already registered`);
}
plugin.initialize(this);
this.plugins.set(plugin.name, plugin);
this.events.emit("plugin:registered", { name: plugin.name });
}
unregister(name: string): void {
const plugin = this.plugins.get(name);
if (plugin) {
plugin.destroy();
this.plugins.delete(name);
this.events.emit("plugin:unregistered", { name });
}
}
on(event: string, handler: (data: unknown) => void): () => void {
return this.events.on(event, handler);
}
emit(event: string, data: unknown): void {
this.events.emit(event, data);
}
}
// Example plugin
class LoggingPlugin implements Plugin {
name = "logging";
version = "1.0.0";
initialize(app: App): void {
app.on("plugin:registered", (data) => {
console.log(`[LOG] Plugin registered: ${(data as { name: string }).name}`);
});
}
destroy(): void {
console.log("[LOG] Logging plugin destroyed");
}
}
// Usage
const app = new App();
app.register(new LoggingPlugin());
app.register(new AnalyticsPlugin());Implementing Decorator Pattern
// Decorator pattern for extending function behavior
type AnyFunction = (...args: unknown[]) => unknown;
function withLogging<T extends AnyFunction>(fn: T): T {
return ((...args: unknown[]) => {
console.log(`Calling ${fn.name} with args:`, args);
const result = fn(...args);
console.log(`${fn.name} returned:`, result);
return result;
}) as T;
}
function withRetry<T extends AnyFunction>(fn: T, maxRetries = 3): T {
return (async (...args: unknown[]) => {
for (let i = 0; i <= maxRetries; i++) {
try {
return await fn(...args);
} catch (err) {
if (i === maxRetries) throw err;
console.log(`Retry ${i + 1}/${maxRetries} for ${fn.name}`);
}
}
}) as T;
}
function withCache<T extends AnyFunction>(fn: T): T {
const cache = new Map<string, unknown>();
return ((...args: unknown[]) => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
}) as T;
}
// Compose decorators
const fetchUser = withRetry(
withLogging(
withCache(async (id: string) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
})
)
);Command Pattern
// Command pattern for undo/redo functionality
interface Command {
execute(): void;
undo(): void;
description: string;
}
class TextEditor {
private content = "";
private history: Command[] = [];
private redoStack: Command[] = [];
executeCommand(command: Command): void {
command.execute();
this.history.push(command);
this.redoStack = []; // Clear redo stack on new action
}
undo(): void {
const command = this.history.pop();
if (command) {
command.undo();
this.redoStack.push(command);
}
}
redo(): void {
const command = this.redoStack.pop();
if (command) {
command.execute();
this.history.push(command);
}
}
getContent(): string {
return this.content;
}
setContent(content: string): void {
this.content = content;
}
}
class InsertTextCommand implements Command {
description: string;
constructor(
private editor: TextEditor,
private text: string,
private position: number
) {
this.description = `Insert "${text}" at position ${position}`;
}
execute(): void {
const content = this.editor.getContent();
this.editor.setContent(
content.slice(0, this.position) + this.text + content.slice(this.position)
);
}
undo(): void {
const content = this.editor.getContent();
this.editor.setContent(
content.slice(0, this.position) + content.slice(this.position + this.text.length)
);
}
}
// Usage
const editor = new TextEditor();
editor.executeCommand(new InsertTextCommand(editor, "Hello", 0));
editor.executeCommand(new InsertTextCommand(editor, " World", 5));
console.log(editor.getContent()); // "Hello World"
editor.undo();
console.log(editor.getContent()); // "Hello"
editor.redo();
console.log(editor.getContent()); // "Hello World"Real-World Use Cases
Use Case 1: State Management with Observer
// Redux-like store using Observer pattern
type Reducer<T> = (state: T, action: { type: string; payload?: unknown }) => T;
class Store<T> {
private state: T;
private listeners = new Set<(state: T) => void>();
constructor(private reducer: Reducer<T>, initialState: T) {
this.state = initialState;
}
getState(): T {
return this.state;
}
dispatch(action: { type: string; payload?: unknown }): void {
this.state = this.reducer(this.state, action);
this.listeners.forEach((listener) => listener(this.state));
}
subscribe(listener: (state: T) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
// Usage
interface AppState {
count: number;
todos: string[];
}
const reducer: Reducer<AppState> = (state, action) => {
switch (action.type) {
case "INCREMENT":
return { ...state, count: state.count + 1 };
case "ADD_TODO":
return { ...state, todos: [...state.todos, action.payload as string] };
default:
return state;
}
};
const store = new Store(reducer, { count: 0, todos: [] });
store.subscribe((state) => {
console.log("State changed:", state);
});
store.dispatch({ type: "INCREMENT" });
store.dispatch({ type: "ADD_TODO", payload: "Learn patterns" });Use Case 2: API Client with Strategy + Decorator
// Flexible API client using multiple patterns
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
interface RequestConfig {
method: HttpMethod;
url: string;
data?: unknown;
headers?: Record<string, string>;
}
type RequestInterceptor = (config: RequestConfig) => RequestConfig;
type ResponseInterceptor = (response: Response) => Response;
class ApiClient {
private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: ResponseInterceptor[] = [];
addRequestInterceptor(interceptor: RequestInterceptor): void {
this.requestInterceptors.push(interceptor);
}
addResponseInterceptor(interceptor: ResponseInterceptor): void {
this.responseInterceptors.push(interceptor);
}
async request(config: RequestConfig): Promise<unknown> {
// Apply request interceptors (Chain of Responsibility pattern)
let processedConfig = config;
for (const interceptor of this.requestInterceptors) {
processedConfig = interceptor(processedConfig);
}
let response = await fetch(processedConfig.url, {
method: processedConfig.method,
headers: processedConfig.headers,
body: processedConfig.data ? JSON.stringify(processedConfig.data) : undefined,
});
// Apply response interceptors
for (const interceptor of this.responseInterceptors) {
response = interceptor(response);
}
return response.json();
}
}
// Usage
const api = new ApiClient();
api.addRequestInterceptor((config) => ({
...config,
headers: {
...config.headers,
Authorization: `Bearer ${getToken()}`,
},
}));
api.addResponseInterceptor((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response;
});Use Case 3: Form Validation Chain
// Chain of Responsibility for form validation
interface ValidatorRule {
validate(value: unknown): string | null;
}
class RequiredRule implements ValidatorRule {
validate(value: unknown): string | null {
if (value === undefined || value === null || value === "") {
return "This field is required";
}
return null;
}
}
class EmailRule implements ValidatorRule {
validate(value: unknown): string | null {
if (typeof value === "string" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return "Invalid email address";
}
return null;
}
}
class MinLengthRule implements ValidatorRule {
constructor(private min: number) {}
validate(value: unknown): string | null {
if (typeof value === "string" && value.length < this.min) {
return `Minimum ${this.min} characters required`;
}
return null;
}
}
class ValidationChain {
private rules = new Map<string, ValidatorRule[]>();
addField(field: string, ...rules: ValidatorRule[]): this {
if (!this.rules.has(field)) {
this.rules.set(field, []);
}
this.rules.get(field)!.push(...rules);
return this;
}
validate(data: Record<string, unknown>): Map<string, string[]> {
const errors = new Map<string, string[]>();
for (const [field, rules] of this.rules) {
const fieldErrors: string[] = [];
for (const rule of rules) {
const error = rule.validate(data[field]);
if (error) fieldErrors.push(error);
}
if (fieldErrors.length > 0) {
errors.set(field, fieldErrors);
}
}
return errors;
}
}
// Usage
const validator = new ValidationChain()
.addField("email", new RequiredRule(), new EmailRule())
.addField("password", new RequiredRule(), new MinLengthRule(8));
const errors = validator.validate({ email: "invalid", password: "123" });
// Map { "email" => ["Invalid email address"], "password" => ["Minimum 8 characters required"] }Best Practices for Production
-
Don't force patterns: If a simple function solves the problem, don't create a class hierarchy. Patterns should simplify code, not add complexity.
-
Prefer composition over inheritance: JavaScript's dynamic nature makes composition patterns (Strategy, Decorator, Observer) more flexible than inheritance-based patterns.
-
Use TypeScript for pattern safety: Type annotations make patterns self-documenting and catch misuse at compile time.
-
Keep patterns simple: A Singleton that's just a module-scoped object is better than a class with static methods and lazy initialization.
-
Document pattern usage: When using a pattern, add a comment explaining which pattern and why. Future maintainers will thank you.
-
Test patterns in isolation: Each pattern should have dedicated tests that verify its contract independent of business logic.
-
Use established libraries: Don't implement Observer from scratch when EventEmitter exists. Use libraries like RxJS for complex reactive patterns.
-
Consider functional alternatives: Many GoF patterns have functional equivalents that are simpler in JavaScript—closures for encapsulation, higher-order functions for Strategy, event emitters for Observer.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Over-engineering with patterns | Unnecessary complexity | Use patterns only when they solve a real problem |
| Singleton as global state | Hidden dependencies, hard to test | Use dependency injection; pass dependencies explicitly |
| Observer memory leaks | Growing memory usage | Always unsubscribe when components unmount |
| Factory returning wrong type | Runtime type errors | Use TypeScript generics to ensure type safety |
| Strategy with too many algorithms | Decision fatigue | Group related strategies; use configuration objects |
| Pattern over-abstraction | Indirection without value | If a pattern doesn't simplify code, remove it |
Performance Optimization
// Use WeakMap for pattern internals to prevent memory leaks
const privateData = new WeakMap<object, Map<string, unknown>>();
class SecureSingleton {
private static instance: SecureSingleton | null = null;
private constructor() {
privateData.set(this, new Map());
}
static getInstance(): SecureSingleton {
if (!SecureSingleton.instance) {
SecureSingleton.instance = new SecureSingleton();
}
return SecureSingleton.instance;
}
set(key: string, value: unknown): void {
privateData.get(this)!.set(key, value);
}
get(key: string): unknown {
return privateData.get(this)!.get(key);
}
}
// WeakMap allows garbage collection when the singleton is no longer referenced// Optimize Observer with batched notifications
class BatchedEventEmitter {
private pending = new Map<string, unknown[]>();
private scheduled = false;
emit(event: string, data: unknown): void {
if (!this.pending.has(event)) {
this.pending.set(event, []);
}
this.pending.get(event)!.push(data);
if (!this.scheduled) {
this.scheduled = true;
queueMicrotask(() => this.flush());
}
}
private flush(): void {
for (const [event, data] of this.pending) {
// Notify all listeners once with batched data
this.notifyListeners(event, data);
}
this.pending.clear();
this.scheduled = false;
}
}Comparison with Alternatives
| Pattern | Classical Implementation | JavaScript Idiom | When to Use |
|---|---|---|---|
| Singleton | Static class | Module-scope object | Database connections, config |
| Observer | Interface + List | EventEmitter | Event-driven architecture |
| Factory | Abstract class | Function returning objects | Object creation varies |
| Strategy | Interface hierarchy | Function references | Algorithm selection |
| Decorator | Wrapper classes | Higher-order functions | Extending behavior |
| Command | Command interface | Function objects | Undo/redo, queuing |
| Proxy | Proxy class | ES6 Proxy | Validation, caching |
Advanced Patterns
ES6 Proxy for AOP
// Aspect-Oriented Programming with Proxy
function withAspect<T extends object>(
target: T,
aspects: {
before?: (method: string, args: unknown[]) => void;
after?: (method: string, args: unknown[], result: unknown) => void;
onError?: (method: string, args: unknown[], error: Error) => void;
}
): T {
return new Proxy(target, {
get(obj, prop) {
if (typeof obj[prop as keyof T] === "function") {
return (...args: unknown[]) => {
aspects.before?.(prop as string, args);
try {
const result = (obj[prop as keyof T] as Function).apply(obj, args);
if (result instanceof Promise) {
return result
.then((res) => {
aspects.after?.(prop as string, args, res);
return res;
})
.catch((err) => {
aspects.onError?.(prop as string, args, err);
throw err;
});
}
aspects.after?.(prop as string, args, result);
return result;
} catch (err) {
aspects.onError?.(prop as string, args, err as Error);
throw err;
}
};
}
return obj[prop as keyof T];
},
});
}
// Usage
const service = withAspect(new UserService(), {
before: (method, args) => console.log(`→ ${method}(${args})`),
after: (method, args, result) => console.log(`← ${method}: ${result}`),
onError: (method, args, err) => console.error(`✗ ${method}: ${err.message}`),
});Testing Strategies
// Test patterns with focused unit tests
import { assertEquals, assertThrows } from "https://deno.land/std/assert/mod.ts";
Deno.test("Singleton returns same instance", () => {
const a = DatabaseConnection.getInstance("postgres://localhost/test");
const b = DatabaseConnection.getInstance();
assertEquals(a, b);
});
Deno.test("Observer notifies subscribers", () => {
const emitter = new EventEmitter();
let received: unknown = null;
emitter.on("test", (data) => { received = data; });
emitter.emit("test", { value: 42 });
assertEquals(received, { value: 42 });
});
Deno.test("Strategy pattern validates correctly", () => {
const validator = new Validator().addRule("email", strategies.email);
const valid = validator.validate({ email: "test@example.com" });
assertEquals(valid.size, 0);
const invalid = validator.validate({ email: "invalid" });
assertEquals(invalid.get("email"), "Invalid email");
});
Deno.test("Command pattern supports undo", () => {
const editor = new TextEditor();
editor.executeCommand(new InsertTextCommand(editor, "Hello", 0));
assertEquals(editor.getContent(), "Hello");
editor.undo();
assertEquals(editor.getContent(), "");
});Future Outlook
Design patterns in JavaScript continue to evolve:
- Reactive patterns with RxJS and Signals are replacing traditional Observer
- Functional patterns (monads, lenses, transducers) are gaining adoption
- Proxy-based metaprogramming enables new pattern implementations
- Module federation creates new architectural patterns for micro-frontends
The core patterns remain relevant, but their implementations continue to become more idiomatic and expressive as JavaScript evolves.
Conclusion
Design patterns are the shared vocabulary of software engineering. In JavaScript, the language's dynamic nature—closures, first-class functions, prototypal inheritance—makes many patterns simpler and more powerful than their classical counterparts.
Key takeaways:
- Patterns are tools, not rules — use them when they solve a real problem
- JavaScript's closures replace many GoF patterns — a closure is often simpler than a class
- Composition beats inheritance — Strategy and Decorator are more flexible than Template Method
- TypeScript adds safety — type annotations make patterns self-documenting
- Test patterns independently — each pattern should have its own test suite
Master these patterns and you'll have the vocabulary and tools to design maintainable, extensible software systems in JavaScript.