Difficulty: Advanced
Design patterns are proven, reusable solutions to commonly occurring problems in software design. They are not specific algorithms or code snippets but rather templates for how to structure code to solve particular types of problems. In JavaScript, design patterns take on unique forms because of the language's prototypal inheritance, first-class functions, and dynamic typing. Understanding these patterns helps you write more maintainable, scalable, and communicable code.
The Singleton pattern ensures that a class or module has only one instance and provides a global point of access to it. In JavaScript, the simplest singleton is a plain object literal or an ES module - since ES modules are cached after first import, any exported object is effectively a singleton. The class-based singleton uses a static method that returns the existing instance or creates one if none exists. Singletons are useful for shared state managers, configuration objects, and database connections.
The Observer pattern (also known as PubSub or Event Emitter) defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified. This is the pattern behind DOM events, Node.js EventEmitter, RxJS observables, and state management libraries. You create a subject that maintains a list of subscribers and provides subscribe, unsubscribe, and notify methods. This pattern decouples the producer of events from their consumers.
The Factory pattern provides an interface for creating objects without specifying their exact class. Instead of using new directly, you call a factory function that decides which type of object to create based on input parameters. This is especially useful when object creation involves complex logic, when you want to centralize creation logic, or when the specific type is determined at runtime. JavaScript's dynamic nature makes factories particularly natural - a factory can return plain objects without needing class hierarchies.
The Module pattern uses closures (or IIFE) to create private scope, exposing only a public API. Before ES modules, this was the primary way to achieve encapsulation in JavaScript. The Strategy pattern defines a family of interchangeable algorithms, allowing the algorithm to vary independently from clients that use it. The Decorator pattern dynamically adds behavior to an object or function without modifying its original code. In JavaScript, higher-order functions are natural decorators - you wrap a function to add logging, caching, validation, or retry logic.
Choosing the right pattern depends on the problem. Use Singleton for shared global state, Observer for event-driven communication, Factory when creation logic is complex or polymorphic, Module for encapsulation, Strategy when you need swappable algorithms, and Decorator when you want to extend behavior without subclassing. The key is recognizing the problem shape - patterns are about intent, not just structure.
// Module-based singleton (simplest in ES modules)
// config.js - the exported object is cached by the module system
// export const config = { apiUrl: '...', debug: false };
// Class-based singleton
class Database {
static #instance = null;
constructor(connectionString) {
if (Database.#instance) {
return Database.#instance;
}
this.connectionString = connectionString;
this.connected = false;
Database.#instance = this;
}
connect() {
this.connected = true;
console.log(`Connected to ${this.connectionString}`);
}
}
const db1 = new Database('postgres://localhost:5432');
const db2 = new Database('mysql://localhost:3306');
console.log(db1 === db2);
console.log(db2.connectionString);
db1.connect();
The constructor returns the existing instance if one exists. Both db1 and db2 are the same object. The second connection string is ignored because the instance was already created.
class EventEmitter {
#listeners = new Map();
on(event, callback) {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
this.#listeners.get(event).add(callback);
return () => this.off(event, callback); // return unsubscribe fn
}
off(event, callback) {
this.#listeners.get(event)?.delete(callback);
}
emit(event, ...args) {
this.#listeners.get(event)?.forEach(cb => cb(...args));
}
}
const bus = new EventEmitter();
const unsub = bus.on('user:login', (user) => {
console.log(`Welcome, ${user.name}!`);
});
bus.on('user:login', (user) => {
console.log(`Logging analytics for ${user.name}`);
});
bus.emit('user:login', { name: 'Alice' });
unsub(); // remove first listener
bus.emit('user:login', { name: 'Bob' });
The EventEmitter decouples event producers from consumers. Listeners subscribe to named events and are called when those events are emitted. The on() method returns an unsubscribe function for easy cleanup.
// Factory pattern
function createNotification(type, message) {
const base = { message, timestamp: Date.now() };
switch (type) {
case 'email':
return { ...base, type: 'email', send: () => console.log(`Email: ${message}`) };
case 'sms':
return { ...base, type: 'sms', send: () => console.log(`SMS: ${message}`) };
case 'push':
return { ...base, type: 'push', send: () => console.log(`Push: ${message}`) };
default:
throw new Error(`Unknown notification type: ${type}`);
}
}
const n1 = createNotification('email', 'Your order shipped');
const n2 = createNotification('sms', 'Code: 123456');
n1.send();
n2.send();
// Strategy pattern
const sortStrategies = {
byName: (a, b) => a.name.localeCompare(b.name),
byAge: (a, b) => a.age - b.age,
byScore: (a, b) => b.score - a.score,
};
function sortUsers(users, strategyName) {
return [...users].sort(sortStrategies[strategyName]);
}
const users = [
{ name: 'Charlie', age: 30, score: 85 },
{ name: 'Alice', age: 25, score: 92 },
{ name: 'Bob', age: 28, score: 78 },
];
console.log(sortUsers(users, 'byName').map(u => u.name));
console.log(sortUsers(users, 'byAge').map(u => u.name));
console.log(sortUsers(users, 'byScore').map(u => u.name));
The factory centralizes object creation logic based on type. The strategy pattern allows swapping sort algorithms at runtime without modifying the sort function itself.
// Decorator: withLogging
function withLogging(fn) {
return function (...args) {
console.log(`Calling ${fn.name}(${args.join(', ')})`);
const result = fn.apply(this, args);
console.log(`Result: ${result}`);
return result;
};
}
// Decorator: withCache (memoization)
function withCache(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`Cache hit for ${fn.name}(${args.join(', ')})`);
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
function multiply(a, b) {
return a * b;
}
const loggedMultiply = withLogging(multiply);
loggedMultiply(3, 4);
const cachedMultiply = withCache(multiply);
console.log(cachedMultiply(5, 6));
console.log(cachedMultiply(5, 6));
console.log(cachedMultiply(7, 8));
Higher-order functions naturally implement the Decorator pattern. withLogging adds logging without modifying the original function. withCache adds memoization. Decorators can be stacked: withLogging(withCache(fn)).
Singleton, Observer, PubSub, Factory, Module, Strategy, Decorator