JavaScript Design Patterns
Introduction
Design patterns are proven solutions to common problems that occur during software development. They represent best practices evolved over time by experienced developers to create maintainable, flexible, and robust code.
In JavaScript, design patterns help you organize your code in a way that makes it more efficient, readable, and scalable. Understanding these patterns will elevate your programming skills from basic syntax knowledge to architectural thinking.
This guide will introduce you to the most common JavaScript design patterns, explain how they work, and demonstrate their practical applications through examples.
Why Design Patterns Matter
Before diving into specific patterns, let's understand why design patterns are essential:
- Code Reusability: Design patterns provide templates that can be applied across multiple projects
- Common Vocabulary: They establish a common language between developers
- Proven Solutions: They represent solutions that have been refined over time
- Scalable Architecture: They help structure applications that can grow without becoming unmanageable
Types of Design Patterns
Design patterns typically fall into three categories:
- Creational Patterns: Focus on object creation mechanisms
- Structural Patterns: Deal with object composition and relationships
- Behavioral Patterns: Concentrate on communication between objects
Let's explore some of the most useful patterns in each category.
Creational Patterns
1. Singleton Pattern
The Singleton pattern ensures a class has only one instance and provides a global point of access to it.
Example:
class DatabaseConnection {
constructor() {
if (DatabaseConnection.instance) {
return DatabaseConnection.instance;
}
this.connectionString = "mongodb://localhost:27017";
this.isConnected = false;
DatabaseConnection.instance = this;
}
connect() {
if (!this.isConnected) {
console.log(`Connecting to database at ${this.connectionString}`);
this.isConnected = true;
return true;
}
console.log("Already connected to database");
return false;
}
}
// Usage
const connection1 = new DatabaseConnection();
const connection2 = new DatabaseConnection();
console.log(connection1 === connection2); // Output: true
connection1.connect(); // Output: "Connecting to database at mongodb://localhost:27017"
connection2.connect(); // Output: "Already connected to database"
In this example, no matter how many times we instantiate DatabaseConnection
, we always get the same instance. This is useful for managing database connections, configuration settings, or any resource that should be limited to a single instance.
2. Factory Pattern
The Factory pattern provides an interface for creating objects without specifying their concrete classes.
Example:
class VehicleFactory {
createVehicle(type) {
switch(type) {
case 'car':
return new Car();
case 'truck':
return new Truck();
case 'motorcycle':
return new Motorcycle();
default:
throw new Error(`Vehicle type ${type} not supported`);
}
}
}
class Car {
drive() {
return "Driving car at 80 mph";
}
}
class Truck {
drive() {
return "Driving truck at 60 mph";
}
}
class Motorcycle {
drive() {
return "Driving motorcycle at 100 mph";
}
}
// Usage
const factory = new VehicleFactory();
const car = factory.createVehicle('car');
const truck = factory.createVehicle('truck');
console.log(car.drive()); // Output: "Driving car at 80 mph"
console.log(truck.drive()); // Output: "Driving truck at 60 mph"
This pattern is helpful when you need to create objects without knowing exactly which subclass you need at compile time.
3. Module Pattern
The Module pattern encapsulates private functionality and exposes a public API, similar to classes in other languages.
Example:
const ShoppingCart = (function() {
// Private variables and methods
let items = [];
function calculateTotal() {
return items.reduce((total, item) => total + item.price * item.quantity, 0);
}
// Public API
return {
addItem: function(name, price, quantity = 1) {
items.push({ name, price, quantity });
},
removeItem: function(name) {
items = items.filter(item => item.name !== name);
},
getItemCount: function() {
return items.reduce((count, item) => count + item.quantity, 0);
},
getTotal: function() {
return calculateTotal();
},
listItems: function() {
return [...items];
}
};
})();
// Usage
ShoppingCart.addItem('Laptop', 999, 1);
ShoppingCart.addItem('Headphones', 99, 2);
console.log(ShoppingCart.getItemCount()); // Output: 3
console.log(ShoppingCart.getTotal()); // Output: 1197
console.log(ShoppingCart.listItems()); // Output: Array of items
The Module pattern allows you to hide implementation details and only expose an interface to the client code.
Structural Patterns
1. Decorator Pattern
The Decorator pattern lets you attach new behaviors to objects by placing them inside wrapper objects.
Example:
// Base coffee component
class Coffee {
cost() {
return 5;
}
description() {
return "Basic coffee";
}
}
// Decorator
class MilkDecorator {
constructor(coffee) {
this.coffee = coffee;
}
cost() {
return this.coffee.cost() + 1.5;
}
description() {
return `${this.coffee.description()}, with milk`;
}
}
// Decorator
class CaramelDecorator {
constructor(coffee) {
this.coffee = coffee;
}
cost() {
return this.coffee.cost() + 2;
}
description() {
return `${this.coffee.description()}, with caramel`;
}
}
// Usage
let myCoffee = new Coffee();
console.log(myCoffee.description()); // Output: "Basic coffee"
console.log(myCoffee.cost()); // Output: 5
myCoffee = new MilkDecorator(myCoffee);
console.log(myCoffee.description()); // Output: "Basic coffee, with milk"
console.log(myCoffee.cost()); // Output: 6.5
myCoffee = new CaramelDecorator(myCoffee);
console.log(myCoffee.description()); // Output: "Basic coffee, with milk, with caramel"
console.log(myCoffee.cost()); // Output: 8.5
The Decorator pattern is excellent for adding features to objects dynamically without modifying their structure.
2. Adapter Pattern
The Adapter pattern allows objects with incompatible interfaces to work together.
Example:
// Old API
class OldCalculator {
operation(term1, term2, operation) {
switch(operation) {
case 'add':
return term1 + term2;
case 'sub':
return term1 - term2;
default:
return NaN;
}
}
}
// New API
class NewCalculator {
add(term1, term2) {
return term1 + term2;
}
subtract(term1, term2) {
return term1 - term2;
}
multiply(term1, term2) {
return term1 * term2;
}
divide(term1, term2) {
return term1 / term2;
}
}
// Adapter
class CalculatorAdapter {
constructor() {
this.calculator = new NewCalculator();
}
operation(term1, term2, operation) {
switch(operation) {
case 'add':
return this.calculator.add(term1, term2);
case 'sub':
return this.calculator.subtract(term1, term2);
case 'mult':
return this.calculator.multiply(term1, term2);
case 'div':
return this.calculator.divide(term1, term2);
default:
return NaN;
}
}
}
// Usage
const oldCalc = new OldCalculator();
console.log(oldCalc.operation(10, 5, 'add')); // Output: 15
// Using new calculator through adapter
const adapter = new CalculatorAdapter();
console.log(adapter.operation(10, 5, 'add')); // Output: 15
console.log(adapter.operation(10, 5, 'mult')); // Output: 50
The Adapter pattern is useful when integrating new systems with existing code or working with third-party libraries that have incompatible interfaces.
Behavioral Patterns
1. Observer Pattern
The Observer pattern establishes a one-to-many relationship between objects. When one object (the subject) changes its state, all its dependents (observers) are notified and updated automatically.
Example:
class Subject {
constructor() {
this.observers = [];
}
subscribe(observer) {
this.observers.push(observer);
}
unsubscribe(observer) {
this.observers = this.observers.filter(obs => obs !== observer);
}
notify(data) {
this.observers.forEach(observer => observer.update(data));
}
}
class Observer {
constructor(name) {
this.name = name;
}
update(data) {
console.log(`${this.name} received update: ${data}`);
}
}
// Usage
const newsPublisher = new Subject();
const john = new Observer('John');
const mary = new Observer('Mary');
const adam = new Observer('Adam');
newsPublisher.subscribe(john);
newsPublisher.subscribe(mary);
newsPublisher.subscribe(adam);
newsPublisher.notify('Breaking news: JavaScript is awesome!');
// Output:
// John received update: Breaking news: JavaScript is awesome!
// Mary received update: Breaking news: JavaScript is awesome!
// Adam received update: Breaking news: JavaScript is awesome!
newsPublisher.unsubscribe(mary);
newsPublisher.notify('Another update: New design patterns tutorial released!');
// Output:
// John received update: Another update: New design patterns tutorial released!
// Adam received update: Another update: New design patterns tutorial released!
The Observer pattern is widely used for implementing event handling systems, reactive programming, and implementing MVC architecture in frontend frameworks.
2. Strategy Pattern
The Strategy pattern enables selecting an algorithm's behavior at runtime. It defines a family of algorithms, encapsulates each one, and makes them interchangeable.
Example:
// Interface for payment strategies
class PaymentStrategy {
pay(amount) {
// To be implemented by subclasses
}
}
// Concrete strategies
class CreditCardStrategy extends PaymentStrategy {
constructor(cardNumber, name, cvv, expirationDate) {
super();
this.cardNumber = cardNumber;
this.name = name;
this.cvv = cvv;
this.expirationDate = expirationDate;
}
pay(amount) {
console.log(`Paid $${amount} using credit card: ${this.cardNumber.slice(-4)}`);
return true;
}
}
class PayPalStrategy extends PaymentStrategy {
constructor(email, password) {
super();
this.email = email;
this.password = password;
}
pay(amount) {
console.log(`Paid $${amount} using PayPal account: ${this.email}`);
return true;
}
}
class BitcoinStrategy extends PaymentStrategy {
constructor(address) {
super();
this.address = address;
}
pay(amount) {
console.log(`Paid $${amount} equivalent in Bitcoin to address: ${this.address}`);
return true;
}
}
// Context
class ShoppingCart {
constructor() {
this.items = [];
this.paymentStrategy = null;
}
addItem(item) {
this.items.push(item);
}
calculateTotal() {
return this.items.reduce((total, item) => total + item.price, 0);
}
setPaymentStrategy(paymentStrategy) {
this.paymentStrategy = paymentStrategy;
}
checkout() {
if (!this.paymentStrategy) {
throw new Error("No payment method selected");
}
const amount = this.calculateTotal();
return this.paymentStrategy.pay(amount);
}
}
// Usage
const cart = new ShoppingCart();
cart.addItem({ name: "Book", price: 20 });
cart.addItem({ name: "Headphones", price: 150 });
// Pay with credit card
cart.setPaymentStrategy(new CreditCardStrategy('1234-5678-9012-3456', 'John Smith', '123', '12/25'));
cart.checkout(); // Output: "Paid $170 using credit card: 3456"
// Pay with PayPal
cart.setPaymentStrategy(new PayPalStrategy('john.smith@example.com', 'password'));
cart.checkout(); // Output: "Paid $170 using PayPal account: john.smith@example.com"
The Strategy pattern is useful when you want to define a family of algorithms, encapsulate each one, and make them interchangeable at runtime.
Practical Applications
Real-World Example: Form Validation System
Let's apply multiple patterns to create a complete form validation system:
// Strategy Pattern for different validation rules
class ValidationStrategy {
validate(value) {
// To be implemented by concrete strategies
}
}
class RequiredFieldStrategy extends ValidationStrategy {
validate(value) {
return value !== undefined && value !== null && value.toString().trim() !== '';
}
get errorMessage() {
return 'This field is required';
}
}
class EmailStrategy extends ValidationStrategy {
validate(value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
}
get errorMessage() {
return 'Please enter a valid email address';
}
}
class MinLengthStrategy extends ValidationStrategy {
constructor(minLength) {
super();
this.minLength = minLength;
}
validate(value) {
return value && value.length >= this.minLength;
}
get errorMessage() {
return `Please enter at least ${this.minLength} characters`;
}
}
// Factory Pattern for creating validation strategies
class ValidationFactory {
static createValidator(type, options = {}) {
switch(type) {
case 'required':
return new RequiredFieldStrategy();
case 'email':
return new EmailStrategy();
case 'minlength':
return new MinLengthStrategy(options.length || 5);
default:
throw new Error(`Validation type '${type}' not supported`);
}
}
}
// Form validator using Multiple patterns
class FormValidator {
constructor() {
this.fields = new Map();
this.errors = new Map();
}
// Add a field with multiple validation strategies
addField(name, validationTypes = []) {
const validators = validationTypes.map(type => {
if (typeof type === 'string') {
return ValidationFactory.createValidator(type);
} else {
return ValidationFactory.createValidator(type.type, type.options);
}
});
this.fields.set(name, validators);
return this;
}
// Validate the form data
validate(formData) {
this.errors.clear();
for (const [fieldName, validators] of this.fields.entries()) {
const value = formData[fieldName];
for (const validator of validators) {
if (!validator.validate(value)) {
if (!this.errors.has(fieldName)) {
this.errors.set(fieldName, []);
}
this.errors.get(fieldName).push(validator.errorMessage);
break; // Stop at first validation error for this field
}
}
}
return this.errors.size === 0;
}
// Get all validation errors
getErrors() {
return Object.fromEntries(this.errors);
}
}
// Usage
const formValidator = new FormValidator();
formValidator
.addField('username', ['required', { type: 'minlength', options: { length: 3 } }])
.addField('email', ['required', 'email'])
.addField('password', ['required', { type: 'minlength', options: { length: 8 } }]);
// Test with valid data
const validData = {
username: 'john_doe',
email: 'john@example.com',
password: 'securepass123'
};
console.log(formValidator.validate(validData)); // Output: true
console.log(formValidator.getErrors()); // Output: {}
// Test with invalid data
const invalidData = {
username: 'jo',
email: 'invalid-email',
password: 'short'
};
console.log(formValidator.validate(invalidData)); // Output: false
console.log(formValidator.getErrors());
// Output: {
// username: ['Please enter at least 3 characters'],
// email: ['Please enter a valid email address'],
// password: ['Please enter at least 8 characters']
// }
In this example, we've combined several design patterns:
- Strategy Pattern: Different validation strategies for different validation rules
- Factory Pattern: Creates appropriate validators based on type
- Facade Pattern:
FormValidator
provides a simple interface that hides the complexity of the validation system
This demonstrates how design patterns can work together to create a flexible, maintainable solution to a common problem.
Summary
JavaScript design patterns provide battle-tested solutions to common programming challenges. By understanding and applying these patterns, you'll write more maintainable, reusable, and flexible code.
We've covered:
- Creational Patterns: Singleton, Factory, and Module patterns for object creation
- Structural Patterns: Decorator and Adapter patterns for object composition
- Behavioral Patterns: Observer and Strategy patterns for object communication
The real-world form validation example showed how combining multiple patterns can create elegant solutions to complex problems.
As you continue your JavaScript journey, try to identify these patterns in libraries and frameworks you use, and look for opportunities to apply them in your code.
Additional Resources
-
Books:
- "Learning JavaScript Design Patterns" by Addy Osmani
- "JavaScript Patterns" by Stoyan Stefanov
-
Online Resources:
Exercises
-
Singleton Exercise: Create a configuration manager as a singleton that loads settings from localStorage and provides methods to get and set configuration values.
-
Factory Exercise: Implement a notification system with a factory that creates different types of notifications (toast, alert, modal) based on message priority.
-
Observer Exercise: Build a simple news feed where users can subscribe to different topics and receive updates when new content is published on those topics.
-
Strategy Exercise: Create a text formatter that can apply different formatting strategies (uppercase, lowercase, title case, snake case) to a string.
-
Combined Patterns Exercise: Design a simple UI component library that uses multiple design patterns to create, style, and manage components like buttons, forms, and modals.
If you spot any mistakes on this website, please let me know at feedback@compilenrun.com. I’d greatly appreciate your feedback! :)