Back to blog

Essential Design Patterns for JavaScript and TypeScript Applications

12 min readBy Mustafa Akkaya

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! 🎨