Essential Design Patterns for JavaScript and TypeScript Applications
Design patterns are proven solutions to recurring problems in software design. They provide a shared vocabulary for developers and make code more maintainable and scalable. Let's explore essential patterns with practical JavaScript and TypeScript implementations.
Why Design Patterns Matter
Design patterns offer several benefits:
- Reusability - Proven solutions to common problems
- Maintainability - Well-structured, readable code
- Communication - Shared vocabulary among developers
- Scalability - Flexible architecture for growth
- Best Practices - Industry-standard approaches
Creational Patterns
Patterns for object creation mechanisms.
Singleton Pattern
Ensures a class has only one instance with global access.
typescript
// database.ts
class Database {
private static instance: Database;
private connection: any;
private constructor() {
// Private constructor prevents direct instantiation
this.connection = this.connect();
}
private connect() {
console.log("Connecting to database...");
return {
/* connection object */
};
}
public static getInstance(): Database {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
public query(sql: string) {
return this.connection.query(sql);
}
}
// Usage
const db1 = Database.getInstance();
const db2 = Database.getInstance();
console.log(db1 === db2); // true - same instance
Modern Alternative with ES Modules:
typescript
// database.ts
class DatabaseConnection {
private connection: any;
constructor() {
this.connection = this.connect();
}
private connect() {
console.log("Connecting to database...");
return {
/* connection object */
};
}
public query(sql: string) {
return this.connection.query(sql);
}
}
// Export single instance
export const db = new DatabaseConnection();
// Usage in other files
import { db } from "./database";
db.query("SELECT * FROM users");
Factory Pattern
Creates objects without specifying exact class.
typescript
// notification-factory.ts
interface Notification {
send(message: string): void
}
class EmailNotification implements Notification {
send(message: string) {
console.log(Sending email: ${message})
}
}
class SMSNotification implements Notification {
send(message: string) {
console.log(Sending SMS: ${message})
}
class PushNotification implements Notification {
send(message: string) {
console.log(Sending push notification: ${message})
}
}
class SlackNotification implements Notification {
send(message: string) {
console.log(Sending Slack message: ${message})
}
}
type NotificationType = 'email' | 'sms' | 'push' | 'slack'
class NotificationFactory {
static createNotification(type: NotificationType): Notification {
switch (type) {
case 'email':
return new EmailNotification()
case 'sms':
return new SMSNotification()
case 'push':
return new PushNotification()
case 'slack':
return new SlackNotification()
default:
throw new Error(Unknown notification type: ${type})
}
}
}
// Usage
const emailNotif = NotificationFactory.createNotification('email')
emailNotif.send('Welcome to our platform!')
const smsNotif = NotificationFactory.createNotification('sms')
smsNotif.send('Your code is 123456')
Builder Pattern
Constructs complex objects step by step.
typescript
// query-builder.ts
class QueryBuilder {
private query: string = "";
private conditions: string[] = [];
private orderByClause: string = "";
private limitValue: number | null = null;
select(fields: string[]) {
this.query = SELECT ${fields.join(", ")};
return this;
}
from(table: string) {
this.query += FROM ${table};
return this;
}
where(condition: string) {
this.conditions.push(condition);
return this;
}
and(condition: string) {
this.conditions.push(condition);
return this;
}
orderBy(field: string, direction: "ASC" | "DESC" = "ASC") {
this.orderByClause = ORDER BY ${field} ${direction};
return this;
}
limit(count: number) {
this.limitValue = count;
return this;
}
build(): string {
let finalQuery = this.query;
if (this.conditions.length > 0) {
finalQuery += WHERE ${this.conditions.join(" AND ")};
}
if (this.orderByClause) {
finalQuery += this.orderByClause;
}
if (this.limitValue) {
finalQuery += LIMIT ${this.limitValue};
}
return finalQuery;
}
}
// Usage
const query = new QueryBuilder()
.select(["id", "name", "email"])
.from("users")
.where("age > 18")
.and('country = "US"')
.orderBy("created_at", "DESC")
.limit(10)
.build();
console.log(query);
// SELECT id, name, email FROM users WHERE age > 18 AND country = "US" ORDER BY created_at DESC LIMIT 10
Structural Patterns
Patterns for composing objects and classes.
Adapter Pattern
Allows incompatible interfaces to work together.
typescript
// payment-adapter.ts
interface PaymentProcessor {
processPayment(amount: number): Promise;
}
// Third-party Stripe API (incompatible interface)
class StripeAPI {
async charge(cents: number, currency: string) {
console.log(Charging ${cents} ${currency} via Stripe);
return { success: true, id: "stripe_123" };
}
}
// Third-party PayPal API (incompatible interface)
class PayPalAPI {
async makePayment(dollars: number) {
console.log(Processing $${dollars} via PayPal);
return { status: "completed", transactionId: "pp_456" };
}
}
// Adapters
class StripeAdapter implements PaymentProcessor {
private stripe = new StripeAPI();
async processPayment(amount: number): Promise {
const result = await this.stripe.charge(amount * 100, "USD");
return result.success;
}
}
class PayPalAdapter implements PaymentProcessor {
private paypal = new PayPalAPI();
async processPayment(amount: number): Promise {
const result = await this.paypal.makePayment(amount);
return result.status === "completed";
}
}
// Usage
async function checkout(processor: PaymentProcessor, amount: number) {
const success = await processor.processPayment(amount);
console.log(success ? "Payment successful" : "Payment failed");
}
await checkout(new StripeAdapter(), 50);
await checkout(new PayPalAdapter(), 100);
Decorator Pattern
Adds new functionality to objects dynamically.
typescript
// logger-decorator.ts
interface Component {
operation(): string;
}
class ConcreteComponent implements Component {
operation(): string {
return "ConcreteComponent";
}
}
// Base Decorator
abstract class Decorator implements Component {
protected component: Component;
constructor(component: Component) {
this.component = component;
}
operation(): string {
return this.component.operation();
}
}
// Concrete Decorators
class LoggerDecorator extends Decorator {
operation(): string {
const result = super.operation();
console.log(Logging: ${result});
return result;
}
}
class TimerDecorator extends Decorator {
operation(): string {
console.time("operation");
const result = super.operation();
console.timeEnd("operation");
return result;
}
}
class ErrorHandlerDecorator extends Decorator {
operation(): string {
try {
return super.operation();
} catch (error) {
console.error("Error occurred:", error);
return "Error handled";
}
}
}
// Usage
let component: Component = new ConcreteComponent();
component = new LoggerDecorator(component);
component = new TimerDecorator(component);
component = new ErrorHandlerDecorator(component);
component.operation();
Modern Function Decorators (TypeScript):
typescript
// method-decorators.ts
function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(Calling ${propertyKey} with args:, args);
const result = originalMethod.apply(this, args);
console.log(Result:, result);
return result;
};
return descriptor;
}
function Measure(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
console.time(propertyKey);
const result = await originalMethod.apply(this, args);
console.timeEnd(propertyKey);
return result;
};
return descriptor;
}
class UserService {
@Log
@Measure
async getUser(id: string) {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 100));
return { id, name: "John Doe" };
}
}
Proxy Pattern
Controls access to an object.
typescript
// api-proxy.ts
interface API {
request(endpoint: string): Promise;
}
class RealAPI implements API {
async request(endpoint: string): Promise {
console.log(Fetching: ${endpoint});
const response = await fetch(endpoint);
return response.json();
}
}
class CachedAPIProxy implements API {
private realAPI: RealAPI;
private cache = new Map();
private cacheDuration = 5 * 60 * 1000; // 5 minutes
constructor() {
this.realAPI = new RealAPI();
}
async request(endpoint: string): Promise {
const cached = this.cache.get(endpoint);
if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
console.log(Cache hit: ${endpoint});
return cached.data;
}
console.log(Cache miss: ${endpoint});
const data = await this.realAPI.request(endpoint);
this.cache.set(endpoint, {
data,
timestamp: Date.now(),
});
return data;
}
}
// Usage
const api = new CachedAPIProxy();
await api.request("/api/users"); // Cache miss
await api.request("/api/users"); // Cache hit
Behavioral Patterns
Patterns for communication between objects.
Observer Pattern
Defines one-to-many dependency between objects.
typescript
// event-emitter.ts
interface Observer {
update(data: any): void;
}
class Subject {
private observers: Observer[] = [];
attach(observer: Observer): void {
this.observers.push(observer);
}
detach(observer: Observer): void {
const index = this.observers.indexOf(observer);
if (index > -1) {
this.observers.splice(index, 1);
}
}
notify(data: any): void {
for (const observer of this.observers) {
observer.update(data);
}
}
}
// Concrete Observers
class EmailObserver implements Observer {
update(data: any): void {
console.log(Sending email notification: ${data.message});
}
}
class SMSObserver implements Observer {
update(data: any): void {
console.log(Sending SMS notification: ${data.message});
}
}
class LogObserver implements Observer {
update(data: any): void {
console.log(Logging event: ${JSON.stringify(data)});
}
}
// Usage
const orderSubject = new Subject();
orderSubject.attach(new EmailObserver());
orderSubject.attach(new SMSObserver());
orderSubject.attach(new LogObserver());
orderSubject.notify({ message: "Order #123 has been shipped" });
Modern Implementation with EventEmitter:
typescript
// modern-event-emitter.ts
type EventHandler = (data: any) => void;
class EventEmitter {
private events = new Map();
on(event: string, handler: EventHandler): void {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event)!.push(handler);
}
off(event: string, handler: EventHandler): void {
const handlers = this.events.get(event);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
}
emit(event: string, data: any): void {
const handlers = this.events.get(event);
if (handlers) {
handlers.forEach((handler) => handler(data));
}
}
once(event: string, handler: EventHandler): void {
const onceHandler = (data: any) => {
handler(data);
this.off(event, onceHandler);
};
this.on(event, onceHandler);
}
}
// Usage
const emitter = new EventEmitter();
emitter.on("user:login", (user) => {
console.log(User logged in: ${user.name});
});
emitter.on("user:login", (user) => {
console.log(Updating last login time for ${user.id});
});
emitter.emit("user:login", { id: "123", name: "John" });
Strategy Pattern
Defines a family of algorithms and makes them interchangeable.
typescript// payment-strategy.ts
interface PaymentStrategy {
pay(amount: number): void;
}
class CreditCardPayment implements PaymentStrategy {
constructor(private cardNumber: string, private cvv: string) {}
pay(amount: number): void {
console.log(
Paying $${amount} with credit card ending in ${this.cardNumber.slice(-4
)}
);
}
}
class PayPalPayment implements PaymentStrategy {
constructor(private email: string) {}
pay(amount: number): void {
console.log(
Paying $${amount} with PayPal account ${this.email});}
}
class CryptoPayment implements PaymentStrategy {
constructor(private walletAddress: string) {}
pay(amount: number): void {
console.log(
Paying $${amount} with crypto wallet ${this.walletAddress});}
}
class ShoppingCart {
private items: any[] = [];
private paymentStrategy: PaymentStrategy | null = null;
addItem(item: any): void {
this.items.push(item);
}
setPaymentStrategy(strategy: PaymentStrategy): void {
this.paymentStrategy = strategy;
}
checkout(): void {
const total = this.items.reduce((sum, item) => sum + item.price, 0);
if (!this.paymentStrategy) {
throw new Error("Payment strategy not set");
}
this.paymentStrategy.pay(total);
}
}
// Usage
const cart = new ShoppingCart();
cart.addItem({ name: "Laptop", price: 999 });
cart.addItem({ name: "Mouse", price: 29 });
cart.setPaymentStrategy(new CreditCardPayment("1234567890123456", "123"));
cart.checkout();
cart.setPaymentStrategy(new PayPalPayment("user@example.com"));
cart.checkout();
Command Pattern
Encapsulates a request as an object.
typescript
// command-pattern.ts
interface Command {
execute(): void;
undo(): void;
}
class TextEditor {
private content: string = "";
write(text: string): void {
this.content += text;
}
delete(length: number): void {
this.content = this.content.slice(0, -length);
}
getContent(): string {
return this.content;
}
}
class WriteCommand implements Command {
private previousContent: string;
constructor(private editor: TextEditor, private text: string) {
this.previousContent = editor.getContent();
}
execute(): void {
this.editor.write(this.text);
}
undo(): void {
const currentLength = this.editor.getContent().length;
const previousLength = this.previousContent.length;
this.editor.delete(currentLength - previousLength);
}
}
class CommandHistory {
private history: Command[] = [];
private current = -1;
execute(command: Command): void {
command.execute();
// Remove commands after current position
this.history = this.history.slice(0, this.current + 1);
this.history.push(command);
this.current++;
}
undo(): void {
if (this.current >= 0) {
this.history[this.current].undo();
this.current--;
}
}
redo(): void {
if (this.current < this.history.length - 1) {
this.current++;
this.history[this.current].execute();
}
}
}
// Usage
const editor = new TextEditor();
const history = new CommandHistory();
history.execute(new WriteCommand(editor, "Hello "));
console.log(editor.getContent()); // "Hello "
history.execute(new WriteCommand(editor, "World!"));
console.log(editor.getContent()); // "Hello World!"
history.undo();
console.log(editor.getContent()); // "Hello "
history.redo();
console.log(editor.getContent()); // "Hello World!"
React-Specific Patterns
Higher-Order Component (HOC)
typescript
// with-loading.tsx
import { ComponentType } from 'react'
interface WithLoadingProps {
isLoading: boolean
}
function withLoading
(
Component: ComponentType
) {
return (props: P & WithLoadingProps) => {
const { isLoading, ...rest } = props
if (isLoading) {
return
Loading...
}
return
}
}
// Usage
interface UserListProps {
users: User[]
}
function UserList({ users }: UserListProps) {
return (
{users.map((user) => (
- {user.name}
))}
)
}
const UserListWithLoading = withLoading(UserList)
// In component
Render Props
typescript
// mouse-tracker.tsx
import { useState, ReactNode } from "react";
interface MousePosition {
x: number;
y: number;
}
interface MouseTrackerProps {
render: (position: MousePosition) => ReactNode;
}
function MouseTracker({ render }: MouseTrackerProps) {
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouseMove = (e: React.MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
return (
{render(position)}
);
}
// Usage
render={({ x, y }) => (
Mouse position: ({x}, {y})
)}
/>;
Compound Components
typescript
// accordion.tsx
import { createContext, useContext, useState, ReactNode } from 'react'
interface AccordionContextType {
activeIndex: number | null
setActiveIndex: (index: number) => void
}
const AccordionContext = createContext(undefined)
function Accordion({ children }: { children: ReactNode }) {
const [activeIndex, setActiveIndex] = useState(null)
return (
{children}
)
}
function AccordionItem({ index, title, children }: any) {
const context = useContext(AccordionContext)
if (!context) throw new Error('AccordionItem must be used within Accordion')
const { activeIndex, setActiveIndex } = context
const isActive = activeIndex === index
return (
{isActive &&
{children}}
)
}
Accordion.Item = AccordionItem
// Usage
Content 1
Content 2
Anti-Patterns to Avoid
❌ God Object - Class doing too many things
❌ Premature Optimization - Optimizing before measuring
❌ Golden Hammer - Using same pattern everywhere
❌ Spaghetti Code - Unstructured, tangled code
❌ Copy-Paste Programming - Duplicating code instead of abstracting
Best Practices
1. Understand the problem first - Don't force patterns
2. KISS Principle - Keep it simple, stupid
3. YAGNI - You aren't gonna need it
4. Favor composition over inheritance - More flexible
5. Program to interfaces, not implementations - Loose coupling
6. Open/Closed Principle - Open for extension, closed for modification
7. Use TypeScript - Type safety prevents errors
8. Write tests - Ensure patterns work correctly
9. Document patterns - Help team understand architecture
10. Review and refactor - Patterns evolve with requirements
Conclusion
Design patterns are tools, not rules. Use them when they solve a real problem and make code more maintainable. Start with simple solutions, and introduce patterns as complexity grows. The best pattern is the one that makes your code easier to understand and maintain.
Remember: patterns should clarify, not complicate! 🎨