Architecture 27 min read

Design Patterns in TypeScript

Published on January 30, 2026

Design Patterns in TypeScript

Design patterns are reusable solutions to common problems in software design. They aren’t specific code, but templates you can adapt to your particular situation. Understanding design patterns helps you write more maintainable, scalable, and easy-to-understand code.

In this article, we’ll explore the most important design patterns organized into three categories: creational, structural, and behavioral. For each pattern, I’ll show a “without pattern” example (the problem) and “with pattern” (the solution), using TypeScript for the examples.

The examples will be small and practical, focusing on understanding the concept rather than complex implementations.

Creational Patterns

Creational patterns focus on how objects are created. They provide ways to create objects while hiding creation logic.

Builder

The Builder pattern allows constructing complex objects step by step. It’s useful when an object has many optional parameters.

❌ Without pattern

// Constructor with many optional parameters
class User {
    constructor(
        public name: string,
        public email: string,
        public age?: number,
        public phone?: string,
        public address?: string,
        public city?: string,
        public country?: string
    ) {}
}

// Hard to use and error-prone
const user = new User(
    "John",
    "john@example.com",
    undefined, // Which parameter is this?
    undefined,
    "123 Main Street",
    "Madrid"
);

✅ With Builder pattern

class User {
    constructor(
        public name: string,
        public email: string,
        public age?: number,
        public phone?: string,
        public address?: string,
        public city?: string,
        public country?: string
    ) {}
}

class UserBuilder {
    private name!: string;
    private email!: string;
    private age?: number;
    private phone?: string;
    private address?: string;
    private city?: string;
    private country?: string;

    setName(name: string): UserBuilder {
        this.name = name;
        return this;
    }

    setEmail(email: string): UserBuilder {
        this.email = email;
        return this;
    }

    setAge(age: number): UserBuilder {
        this.age = age;
        return this;
    }

    setPhone(phone: string): UserBuilder {
        this.phone = phone;
        return this;
    }

    setAddress(address: string): UserBuilder {
        this.address = address;
        return this;
    }

    setCity(city: string): UserBuilder {
        this.city = city;
        return this;
    }

    setCountry(country: string): UserBuilder {
        this.country = country;
        return this;
    }

    build(): User {
        return new User(
            this.name,
            this.email,
            this.age,
            this.phone,
            this.address,
            this.city,
            this.country
        );
    }
}

// Clear, readable usage
const user = new UserBuilder()
    .setName("John")
    .setEmail("john@example.com")
    .setAddress("123 Main Street")
    .setCity("Madrid")
    .build();

Factory Method

The Factory Method pattern provides an interface for creating objects, but lets subclasses decide which class to instantiate.

❌ Without pattern

// Creation logic mixed with client code
function createPayment(method: string, amount: number) {
    if (method === "credit_card") {
        return new CreditCardPayment(amount);
    } else if (method === "paypal") {
        return new PayPalPayment(amount);
    } else if (method === "crypto") {
        return new CryptoPayment(amount);
    }
    throw new Error("Invalid payment method");
}

✅ With Factory Method pattern

interface Payment {
    process(): void;
}

class CreditCardPayment implements Payment {
    constructor(private amount: number) {}
    process() {
        console.log(`Processing credit card payment: ${this.amount}`);
    }
}

class PayPalPayment implements Payment {
    constructor(private amount: number) {}
    process() {
        console.log(`Processing PayPal payment: ${this.amount}`);
    }
}

abstract class PaymentFactory {
    abstract createPayment(amount: number): Payment;

    processPayment(amount: number): void {
        const payment = this.createPayment(amount);
        payment.process();
    }
}

class CreditCardFactory extends PaymentFactory {
    createPayment(amount: number): Payment {
        return new CreditCardPayment(amount);
    }
}

class PayPalFactory extends PaymentFactory {
    createPayment(amount: number): Payment {
        return new PayPalPayment(amount);
    }
}

// Usage
const factory = new CreditCardFactory();
factory.processPayment(100);

Abstract Factory

The Abstract Factory pattern provides an interface for creating families of related objects without specifying their concrete classes.

❌ Without pattern

// Creation coupled to concrete classes
class WindowsButton {
    render() {
        console.log("Windows button");
    }
}

class MacButton {
    render() {
        console.log("Mac button");
    }
}

// Client code must know all classes
function createUI(os: string) {
    if (os === "windows") {
        return new WindowsButton();
    } else {
        return new MacButton();
    }
}

✅ With Abstract Factory pattern

interface Button {
    render(): void;
}

interface Checkbox {
    render(): void;
}

class WindowsButton implements Button {
    render() {
        console.log("Windows button");
    }
}

class MacButton implements Button {
    render() {
        console.log("Mac button");
    }
}

class WindowsCheckbox implements Checkbox {
    render() {
        console.log("Windows checkbox");
    }
}

class MacCheckbox implements Checkbox {
    render() {
        console.log("Mac checkbox");
    }
}

interface UIFactory {
    createButton(): Button;
    createCheckbox(): Checkbox;
}

class WindowsFactory implements UIFactory {
    createButton(): Button {
        return new WindowsButton();
    }
    createCheckbox(): Checkbox {
        return new WindowsCheckbox();
    }
}

class MacFactory implements UIFactory {
    createButton(): Button {
        return new MacButton();
    }
    createCheckbox(): Checkbox {
        return new MacCheckbox();
    }
}

// Usage: create families of related objects
function createUI(factory: UIFactory) {
    const button = factory.createButton();
    const checkbox = factory.createCheckbox();
    button.render();
    checkbox.render();
}

createUI(new WindowsFactory());

Prototype

The Prototype pattern allows creating new objects by copying existing instances (prototypes), instead of creating instances from scratch.

❌ Without pattern

// Create objects from scratch every time
class Document {
    constructor(
        public title: string,
        public content: string,
        public author: string
    ) {}
}

// Every time you need a similar document, you create it from scratch
const doc1 = new Document("Report", "Content...", "John");
const doc2 = new Document("Report", "Content...", "John"); // Duplication

✅ With Prototype pattern

interface Cloneable {
    clone(): Cloneable;
}

class Document implements Cloneable {
    constructor(
        public title: string,
        public content: string,
        public author: string
    ) {}

    clone(): Document {
        return new Document(this.title, this.content, this.author);
    }
}

// Create prototype
const reportTemplate = new Document("Report", "Template content", "System");

// Clone when you need a new one
const doc1 = reportTemplate.clone();
doc1.title = "Q1 Report";

const doc2 = reportTemplate.clone();
doc2.title = "Q2 Report";

Immutability

Although not a traditional GoF pattern, immutability is an important principle that prevents side effects.

❌ Without immutability

// Mutable objects can cause unexpected bugs
class User {
    constructor(public name: string, public age: number) {}
}

const user = new User("John", 25);
user.age = 26; // Direct mutation

function updateAge(user: User, newAge: number) {
    user.age = newAge; // Side effect
}

updateAge(user, 30);
console.log(user.age); // 30 - original object changed

✅ With immutability

// Use readonly and create new objects instead of mutating
class User {
    constructor(
        public readonly name: string,
        public readonly age: number
    ) {}
}

const user = new User("John", 25);

// Create new object instead of mutating
function updateAge(user: User, newAge: number): User {
    return new User(user.name, newAge);
}

const updatedUser = updateAge(user, 30);
console.log(user.age); // 25 - original unchanged
console.log(updatedUser.age); // 30 - new object

Singleton

The Singleton pattern ensures a class has only one instance and provides a global access point to it.

❌ Without pattern

// Multiple instances can cause problems
class DatabaseConnection {
    constructor() {
        console.log("Connecting to database...");
    }
}

// Every time you create an instance, a new connection is created
const db1 = new DatabaseConnection();
const db2 = new DatabaseConnection(); // Unnecessary new connection

✅ With Singleton pattern

class DatabaseConnection {
    private static instance: DatabaseConnection;

    private constructor() {
        console.log("Connecting to database...");
    }

    public static getInstance(): DatabaseConnection {
        if (!DatabaseConnection.instance) {
            DatabaseConnection.instance = new DatabaseConnection();
        }
        return DatabaseConnection.instance;
    }
}

// You always get the same instance
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance(); // Same instance

Factory Function

A factory function is a function that returns an object. It’s simpler than Factory Method and useful for creating objects with configuration.

❌ Without pattern

// Creating objects directly can be repetitive
class Product {
    constructor(
        public name: string,
        public price: number,
        public category: string
    ) {}
}

const product1 = new Product("Laptop", 1000, "Electronics");
const product2 = new Product("Book", 20, "Books");
// Repeating the same creation logic

✅ With Factory Function

interface Product {
    name: string;
    price: number;
    category: string;
}

function createProduct(
    name: string,
    price: number,
    category: string
): Product {
    // Centralized validation or configuration logic
    if (price < 0) {
        throw new Error("Price cannot be negative");
    }

    return {
        name,
        price,
        category,
        createdAt: new Date(), // Add fields automatically
    };
}

// Simple, consistent usage
const product1 = createProduct("Laptop", 1000, "Electronics");
const product2 = createProduct("Book", 20, "Books");

Structural Patterns

Structural patterns focus on how classes and objects are composed to form larger structures.

Adapter

The Adapter pattern allows incompatible interfaces to work together.

❌ Without pattern

// Incompatible interfaces
class OldPaymentSystem {
    pay(amount: number) {
        console.log(`Paying ${amount} with old system`);
    }
}

class NewPaymentSystem {
    processPayment(amount: number) {
        console.log(`Processing payment of ${amount}`);
    }
}

// Client code must handle both interfaces
function handlePayment(system: any, amount: number) {
    if (system.pay) {
        system.pay(amount);
    } else if (system.processPayment) {
        system.processPayment(amount);
    }
}

✅ With Adapter pattern

interface PaymentProcessor {
    process(amount: number): void;
}

class NewPaymentSystem {
    processPayment(amount: number) {
        console.log(`Processing payment of ${amount}`);
    }
}

class OldPaymentSystem {
    pay(amount: number) {
        console.log(`Paying ${amount} with old system`);
    }
}

// Adapter adapts the old interface to the new one
class OldPaymentAdapter implements PaymentProcessor {
    constructor(private oldSystem: OldPaymentSystem) {}

    process(amount: number): void {
        this.oldSystem.pay(amount);
    }
}

// Now both systems use the same interface
function handlePayment(processor: PaymentProcessor, amount: number) {
    processor.process(amount);
}

const newSystem = new NewPaymentSystem();
const oldSystem = new OldPaymentAdapter(new OldPaymentSystem());

handlePayment(newSystem, 100);
handlePayment(oldSystem, 100);

Bridge

The Bridge pattern separates an abstraction from its implementation, allowing both to vary independently.

❌ Without pattern

// Combination of abstraction and implementation
class RedCircle {
    draw() {
        console.log("Drawing red circle");
    }
}

class BlueCircle {
    draw() {
        console.log("Drawing blue circle");
    }
}

// For each combination you need a new class
// RedSquare, BlueSquare, GreenCircle, etc.

✅ With Bridge pattern

// Implementation
interface Color {
    apply(): string;
}

class Red implements Color {
    apply() {
        return "red";
    }
}

class Blue implements Color {
    apply() {
        return "blue";
    }
}

// Abstraction
abstract class Shape {
    constructor(protected color: Color) {}

    abstract draw(): void;
}

class Circle extends Shape {
    draw() {
        console.log(`Drawing ${this.color.apply()} circle`);
    }
}

class Square extends Shape {
    draw() {
        console.log(`Drawing ${this.color.apply()} square`);
    }
}

// Now you can combine independently
const redCircle = new Circle(new Red());
const blueSquare = new Square(new Blue());
redCircle.draw();
blueSquare.draw();

Composite

The Composite pattern allows composing objects into tree structures and treating individual objects and compositions uniformly.

❌ Without pattern

// Treat individual objects and groups differently
class File {
    constructor(public name: string) {}
    getSize() {
        return 100;
    }
}

class Folder {
    private files: File[] = [];
    addFile(file: File) {
        this.files.push(file);
    }
    getSize() {
        return this.files.reduce((sum, file) => sum + file.getSize(), 0);
    }
}

// Client code must know if it's File or Folder
function calculateSize(item: File | Folder) {
    if (item instanceof File) {
        return item.getSize();
    } else {
        return item.getSize();
    }
}

✅ With Composite pattern

interface FileSystemItem {
    getSize(): number;
    getName(): string;
}

class File implements FileSystemItem {
    constructor(private name: string, private size: number) {}

    getName() {
        return this.name;
    }

    getSize() {
        return this.size;
    }
}

class Folder implements FileSystemItem {
    private children: FileSystemItem[] = [];

    constructor(private name: string) {}

    getName() {
        return this.name;
    }

    add(item: FileSystemItem) {
        this.children.push(item);
    }

    getSize() {
        return this.children.reduce((sum, item) => sum + item.getSize(), 0);
    }
}

// Now you can treat File and Folder the same way
function calculateSize(item: FileSystemItem) {
    return item.getSize();
}

const file1 = new File("file1.txt", 100);
const file2 = new File("file2.txt", 200);
const folder = new Folder("documents");
folder.add(file1);
folder.add(file2);

console.log(calculateSize(folder)); // 300

Decorator

The Decorator pattern allows adding behavior to objects dynamically without altering their structure.

❌ Without pattern

// Create subclasses for each combination of features
class Coffee {
    cost() {
        return 5;
    }
}

class CoffeeWithMilk extends Coffee {
    cost() {
        return super.cost() + 2;
    }
}

class CoffeeWithSugar extends Coffee {
    cost() {
        return super.cost() + 1;
    }
}

class CoffeeWithMilkAndSugar extends Coffee {
    cost() {
        return 5 + 2 + 1;
    }
}

// Class explosion for each combination

✅ With Decorator pattern

interface Coffee {
    cost(): number;
    description(): string;
}

class SimpleCoffee implements Coffee {
    cost() {
        return 5;
    }
    description() {
        return "Coffee";
    }
}

abstract class CoffeeDecorator implements Coffee {
    constructor(protected coffee: Coffee) {}

    abstract cost(): number;
    abstract description(): string;
}

class MilkDecorator extends CoffeeDecorator {
    cost() {
        return this.coffee.cost() + 2;
    }
    description() {
        return this.coffee.description() + ", Milk";
    }
}

class SugarDecorator extends CoffeeDecorator {
    cost() {
        return this.coffee.cost() + 1;
    }
    description() {
        return this.coffee.description() + ", Sugar";
    }
}

// Combine decorators dynamically
let coffee: Coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SugarDecorator(coffee);

console.log(coffee.description()); // "Coffee, Milk, Sugar"
console.log(coffee.cost()); // 8

Facade

The Facade pattern provides a simplified interface to a complex subsystem.

❌ Without pattern

// Client must know and use multiple subsystem classes
class CPU {
    start() {
        console.log("CPU starting");
    }
}

class Memory {
    load() {
        console.log("Memory loading");
    }
}

class HardDrive {
    read() {
        console.log("Hard drive reading");
    }
}

// Client must orchestrate everything manually
function startComputer() {
    const cpu = new CPU();
    const memory = new Memory();
    const hardDrive = new HardDrive();

    cpu.start();
    memory.load();
    hardDrive.read();
}

✅ With Facade pattern

class CPU {
    start() {
        console.log("CPU starting");
    }
}

class Memory {
    load() {
        console.log("Memory loading");
    }
}

class HardDrive {
    read() {
        console.log("Hard drive reading");
    }
}

// Facade simplifies the interface
class ComputerFacade {
    private cpu: CPU;
    private memory: Memory;
    private hardDrive: HardDrive;

    constructor() {
        this.cpu = new CPU();
        this.memory = new Memory();
        this.hardDrive = new HardDrive();
    }

    start() {
        this.cpu.start();
        this.memory.load();
        this.hardDrive.read();
        console.log("Computer started");
    }
}

// Client only uses the simple interface
const computer = new ComputerFacade();
computer.start();

Flyweight

The Flyweight pattern minimizes memory usage by sharing common data among multiple objects.

❌ Without pattern

// Each object stores all its data, even shared data
class Tree {
    constructor(
        public x: number,
        public y: number,
        public type: string,
        public color: string,
        public texture: string
    ) {}
}

// Creating 1000 trees duplicates shared data
const trees: Tree[] = [];
for (let i = 0; i < 1000; i++) {
    trees.push(new Tree(i, i, "Oak", "green", "oak-texture.png"));
}
// Each tree stores "Oak", "green", "oak-texture.png" - memory waste

✅ With Flyweight pattern

// Intrinsic data (shared)
class TreeType {
    constructor(
        public name: string,
        public color: string,
        public texture: string
    ) {}
}

// Flyweight Factory
class TreeTypeFactory {
    private static types: Map<string, TreeType> = new Map();

    static getTreeType(name: string, color: string, texture: string): TreeType {
        const key = `${name}-${color}-${texture}`;
        if (!this.types.has(key)) {
            this.types.set(key, new TreeType(name, color, texture));
        }
        return this.types.get(key)!;
    }
}

// Extrinsic data (unique per instance)
class Tree {
    constructor(
        public x: number,
        public y: number,
        public type: TreeType
    ) {}
}

// Now shared data is stored only once
const treeType = TreeTypeFactory.getTreeType("Oak", "green", "oak-texture.png");
const trees: Tree[] = [];
for (let i = 0; i < 1000; i++) {
    trees.push(new Tree(i, i, treeType));
}
// All share the same TreeType - memory savings

Proxy

The Proxy pattern provides a surrogate or placeholder for another object to control access to it.

❌ Without pattern

// Direct access without control
class Image {
    constructor(private filename: string) {
        this.load(); // Immediate load, even if not used
    }

    private load() {
        console.log(`Loading ${this.filename}...`);
    }

    display() {
        console.log(`Displaying ${this.filename}`);
    }
}

// Loads image even if never displayed
const image = new Image("large-image.jpg");

✅ With Proxy pattern

interface ImageInterface {
    display(): void;
}

class RealImage implements ImageInterface {
    constructor(private filename: string) {
        this.load();
    }

    private load() {
        console.log(`Loading ${this.filename}...`);
    }

    display() {
        console.log(`Displaying ${this.filename}`);
    }
}

class ImageProxy implements ImageInterface {
    private realImage: RealImage | null = null;

    constructor(private filename: string) {}

    display() {
        if (!this.realImage) {
            this.realImage = new RealImage(this.filename);
        }
        this.realImage.display();
    }
}

// Image only loads when needed
const image = new ImageProxy("large-image.jpg");
// Not loaded yet
image.display(); // Now loads and displays

Behavioral Patterns

Behavioral patterns focus on communication between objects and assignment of responsibilities.

Chain of Responsibility

The Chain of Responsibility pattern passes requests along a chain of handlers.

❌ Without pattern

// Handling logic coupled together
function processRequest(amount: number) {
    if (amount < 100) {
        console.log("Manager approves");
    } else if (amount < 1000) {
        console.log("Director approves");
    } else {
        console.log("CEO approves");
    }
}

✅ With Chain of Responsibility pattern

interface Handler {
    setNext(handler: Handler): Handler;
    handle(amount: number): void;
}

abstract class Approver implements Handler {
    private nextHandler?: Handler;

    setNext(handler: Handler): Handler {
        this.nextHandler = handler;
        return handler;
    }

    handle(amount: number): void {
        if (this.canApprove(amount)) {
            this.approve(amount);
        } else if (this.nextHandler) {
            this.nextHandler.handle(amount);
        }
    }

    protected abstract canApprove(amount: number): boolean;
    protected abstract approve(amount: number): void;
}

class Manager extends Approver {
    protected canApprove(amount: number): boolean {
        return amount < 100;
    }
    protected approve(amount: number): void {
        console.log("Manager approves");
    }
}

class Director extends Approver {
    protected canApprove(amount: number): boolean {
        return amount < 1000;
    }
    protected approve(amount: number): void {
        console.log("Director approves");
    }
}

class CEO extends Approver {
    protected canApprove(amount: number): boolean {
        return true;
    }
    protected approve(amount: number): void {
        console.log("CEO approves");
    }
}

// Build the chain
const manager = new Manager();
const director = new Director();
const ceo = new CEO();

manager.setNext(director).setNext(ceo);

manager.handle(50); // Manager approves
manager.handle(500); // Director approves
manager.handle(5000); // CEO approves

Command

The Command pattern encapsulates a request as an object, allowing parameterization of clients with different requests.

❌ Without pattern

// Direct method calls
class Light {
    on() {
        console.log("Light is on");
    }
    off() {
        console.log("Light is off");
    }
}

const light = new Light();
light.on();
light.off();
// You can't undo, queue, or log commands

✅ With Command pattern

interface Command {
    execute(): void;
    undo(): void;
}

class Light {
    private isOn = false;

    on() {
        this.isOn = true;
        console.log("Light is on");
    }

    off() {
        this.isOn = false;
        console.log("Light is off");
    }

    getState() {
        return this.isOn;
    }
}

class LightOnCommand implements Command {
    constructor(private light: Light) {}

    execute() {
        this.light.on();
    }

    undo() {
        this.light.off();
    }
}

class LightOffCommand implements Command {
    constructor(private light: Light) {}

    execute() {
        this.light.off();
    }

    undo() {
        this.light.on();
    }
}

class RemoteControl {
    private command?: Command;
    private lastCommand?: Command;

    setCommand(command: Command) {
        this.command = command;
    }

    pressButton() {
        if (this.command) {
            this.command.execute();
            this.lastCommand = this.command;
        }
    }

    pressUndo() {
        if (this.lastCommand) {
            this.lastCommand.undo();
        }
    }
}

// Usage
const light = new Light();
const lightOn = new LightOnCommand(light);
const remote = new RemoteControl();

remote.setCommand(lightOn);
remote.pressButton(); // Light is on
remote.pressUndo(); // Light is off

Iterator

The Iterator pattern provides a way to access elements of a collection sequentially without exposing its internal representation.

❌ Without pattern

// Exposing internal structure
class BookCollection {
    private books: string[] = [];

    add(book: string) {
        this.books.push(book);
    }

    getBooks() {
        return this.books; // Exposes internal array
    }
}

const collection = new BookCollection();
collection.add("Book 1");
collection.add("Book 2");

// Client depends on internal implementation
const books = collection.getBooks();
for (let i = 0; i < books.length; i++) {
    console.log(books[i]);
}

✅ With Iterator pattern

interface Iterator<T> {
    hasNext(): boolean;
    next(): T;
}

interface Iterable<T> {
    createIterator(): Iterator<T>;
}

class BookCollection implements Iterable<string> {
    private books: string[] = [];

    add(book: string) {
        this.books.push(book);
    }

    createIterator(): Iterator<string> {
        return new BookIterator(this.books);
    }
}

class BookIterator implements Iterator<string> {
    private index = 0;

    constructor(private books: string[]) {}

    hasNext(): boolean {
        return this.index < this.books.length;
    }

    next(): string {
        return this.books[this.index++];
    }
}

// Client doesn't know the internal implementation
const collection = new BookCollection();
collection.add("Book 1");
collection.add("Book 2");

const iterator = collection.createIterator();
while (iterator.hasNext()) {
    console.log(iterator.next());
}

Mediator

The Mediator pattern defines how a set of objects interact, promoting loose coupling by avoiding explicit references between objects.

❌ Without pattern

// Objects communicate directly with each other
class User {
    constructor(public name: string) {}

    sendMessage(message: string, to: User) {
        console.log(`${this.name} to ${to.name}: ${message}`);
    }
}

const user1 = new User("Alice");
const user2 = new User("Bob");
const user3 = new User("Charlie");

// Each user must know the others
user1.sendMessage("Hello", user2);
user2.sendMessage("Hi", user1);
// Strong coupling between users

✅ With Mediator pattern

interface Mediator {
    sendMessage(message: string, from: User, to?: User): void;
}

class ChatMediator implements Mediator {
    private users: User[] = [];

    addUser(user: User) {
        this.users.push(user);
        user.setMediator(this);
    }

    sendMessage(message: string, from: User, to?: User): void {
        if (to) {
            // Private message
            console.log(`${from.name} to ${to.name}: ${message}`);
        } else {
            // Broadcast to all
            this.users.forEach(user => {
                if (user !== from) {
                    console.log(`${from.name} to ${user.name}: ${message}`);
                }
            });
        }
    }
}

class User {
    private mediator?: Mediator;

    constructor(public name: string) {}

    setMediator(mediator: Mediator) {
        this.mediator = mediator;
    }

    send(message: string, to?: User) {
        if (this.mediator) {
            this.mediator.sendMessage(message, this, to);
        }
    }
}

// Users don't know each other, they only know the mediator
const mediator = new ChatMediator();
const user1 = new User("Alice");
const user2 = new User("Bob");
const user3 = new User("Charlie");

mediator.addUser(user1);
mediator.addUser(user2);
mediator.addUser(user3);

user1.send("Hello everyone"); // Broadcast
user2.send("Hi", user1); // Private

Memento

The Memento pattern allows capturing and restoring an object’s internal state without violating encapsulation.

❌ Without pattern

// Exposing internal state to save/restore
class Editor {
    public content: string = "";

    type(text: string) {
        this.content += text;
    }
}

const editor = new Editor();
editor.type("Hello");
const savedContent = editor.content; // Expose internal state
editor.type(" World");

// Restore
editor.content = savedContent; // Violate encapsulation

✅ With Memento pattern

class Memento {
    constructor(private state: string) {}

    getState(): string {
        return this.state;
    }
}

class Editor {
    private content: string = "";

    type(text: string) {
        this.content += text;
    }

    getContent(): string {
        return this.content;
    }

    save(): Memento {
        return new Memento(this.content);
    }

    restore(memento: Memento) {
        this.content = memento.getState();
    }
}

class History {
    private mementos: Memento[] = [];

    push(memento: Memento) {
        this.mementos.push(memento);
    }

    pop(): Memento | undefined {
        return this.mementos.pop();
    }
}

// Usage
const editor = new Editor();
const history = new History();

editor.type("Hello");
history.push(editor.save());

editor.type(" World");
console.log(editor.getContent()); // "Hello World"

editor.restore(history.pop()!);
console.log(editor.getContent()); // "Hello"

Observer

The Observer pattern defines a one-to-many dependency between objects, so that when one object changes state, all its dependents are notified.

❌ Without pattern

// Strong coupling: subject must know all observers
class NewsPublisher {
    private subscribers: string[] = [];

    subscribe(email: string) {
        this.subscribers.push(email);
    }

    notify(news: string) {
        // Must know how to notify each type of subscriber
        this.subscribers.forEach(email => {
            console.log(`Email to ${email}: ${news}`);
        });
    }
}

✅ With Observer pattern

interface Observer {
    update(data: string): void;
}

interface Subject {
    subscribe(observer: Observer): void;
    unsubscribe(observer: Observer): void;
    notify(data: string): void;
}

class NewsPublisher implements Subject {
    private observers: Observer[] = [];

    subscribe(observer: Observer) {
        this.observers.push(observer);
    }

    unsubscribe(observer: Observer) {
        this.observers = this.observers.filter(o => o !== observer);
    }

    notify(news: string) {
        this.observers.forEach(observer => observer.update(news));
    }
}

class EmailSubscriber implements Observer {
    constructor(private email: string) {}

    update(news: string) {
        console.log(`Email to ${this.email}: ${news}`);
    }
}

class SMSSubscriber implements Observer {
    constructor(private phone: string) {}

    update(news: string) {
        console.log(`SMS to ${this.phone}: ${news}`);
    }
}

// Usage
const publisher = new NewsPublisher();
const emailSub = new EmailSubscriber("user@example.com");
const smsSub = new SMSSubscriber("123456789");

publisher.subscribe(emailSub);
publisher.subscribe(smsSub);

publisher.notify("Breaking news!");

State

The State pattern allows an object to alter its behavior when its internal state changes.

❌ Without pattern

// State logic mixed with if/else
class Document {
    private state: string = "draft";

    publish() {
        if (this.state === "draft") {
            this.state = "published";
            console.log("Document published");
        } else if (this.state === "published") {
            console.log("Already published");
        } else if (this.state === "archived") {
            console.log("Cannot publish archived document");
        }
    }

    archive() {
        if (this.state === "draft") {
            console.log("Cannot archive draft");
        } else if (this.state === "published") {
            this.state = "archived";
            console.log("Document archived");
        } else if (this.state === "archived") {
            console.log("Already archived");
        }
    }
}

✅ With State pattern

interface DocumentState {
    publish(): void;
    archive(): void;
}

class DraftState implements DocumentState {
    constructor(private document: Document) {}

    publish() {
        console.log("Document published");
        this.document.setState(new PublishedState(this.document));
    }

    archive() {
        console.log("Cannot archive draft");
    }
}

class PublishedState implements DocumentState {
    constructor(private document: Document) {}

    publish() {
        console.log("Already published");
    }

    archive() {
        console.log("Document archived");
        this.document.setState(new ArchivedState(this.document));
    }
}

class ArchivedState implements DocumentState {
    constructor(private document: Document) {}

    publish() {
        console.log("Cannot publish archived document");
    }

    archive() {
        console.log("Already archived");
    }
}

class Document {
    private state: DocumentState;

    constructor() {
        this.state = new DraftState(this);
    }

    setState(state: DocumentState) {
        this.state = state;
    }

    publish() {
        this.state.publish();
    }

    archive() {
        this.state.archive();
    }
}

// Usage
const doc = new Document();
doc.publish(); // Document published
doc.archive(); // Document archived

Strategy

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable.

❌ Without pattern

// Algorithm logic mixed with if/else
class PaymentProcessor {
    process(amount: number, method: string) {
        if (method === "credit_card") {
            console.log(`Processing ${amount} with credit card (fee: 2%)`);
            return amount * 1.02;
        } else if (method === "paypal") {
            console.log(`Processing ${amount} with PayPal (fee: 3%)`);
            return amount * 1.03;
        } else if (method === "crypto") {
            console.log(`Processing ${amount} with crypto (fee: 1%)`);
            return amount * 1.01;
        }
    }
}

✅ With Strategy pattern

interface PaymentStrategy {
    process(amount: number): number;
}

class CreditCardStrategy implements PaymentStrategy {
    process(amount: number): number {
        console.log(`Processing ${amount} with credit card (fee: 2%)`);
        return amount * 1.02;
    }
}

class PayPalStrategy implements PaymentStrategy {
    process(amount: number): number {
        console.log(`Processing ${amount} with PayPal (fee: 3%)`);
        return amount * 1.03;
    }
}

class CryptoStrategy implements PaymentStrategy {
    process(amount: number): number {
        console.log(`Processing ${amount} with crypto (fee: 1%)`);
        return amount * 1.01;
    }
}

class PaymentProcessor {
    private strategy?: PaymentStrategy;

    setStrategy(strategy: PaymentStrategy) {
        this.strategy = strategy;
    }

    process(amount: number): number {
        if (!this.strategy) {
            throw new Error("No payment strategy set");
        }
        return this.strategy.process(amount);
    }
}

// Usage
const processor = new PaymentProcessor();
processor.setStrategy(new CreditCardStrategy());
processor.process(100); // Processing 100 with credit card (fee: 2%)

processor.setStrategy(new CryptoStrategy());
processor.process(100); // Processing 100 with crypto (fee: 1%)

Template Method

The Template Method pattern defines the skeleton of an algorithm in a base class, allowing subclasses to override specific steps.

❌ Without pattern

// Code duplication in similar algorithms
class CoffeeMaker {
    makeCoffee() {
        console.log("Boiling water");
        console.log("Brewing coffee");
        console.log("Pouring into cup");
        console.log("Adding sugar and milk");
    }
}

class TeaMaker {
    makeTea() {
        console.log("Boiling water");
        console.log("Steeping tea");
        console.log("Pouring into cup");
        console.log("Adding lemon");
    }
}
// Lots of duplicated code

✅ With Template Method pattern

abstract class BeverageMaker {
    // Template method: defines the algorithm
    make(): void {
        this.boilWater();
        this.brew();
        this.pourInCup();
        this.addCondiments();
    }

    // Common steps
    private boilWater() {
        console.log("Boiling water");
    }

    private pourInCup() {
        console.log("Pouring into cup");
    }

    // Steps that vary - must be implemented by subclasses
    protected abstract brew(): void;
    protected abstract addCondiments(): void;
}

class CoffeeMaker extends BeverageMaker {
    protected brew() {
        console.log("Brewing coffee");
    }

    protected addCondiments() {
        console.log("Adding sugar and milk");
    }
}

class TeaMaker extends BeverageMaker {
    protected brew() {
        console.log("Steeping tea");
    }

    protected addCondiments() {
        console.log("Adding lemon");
    }
}

// Usage
const coffee = new CoffeeMaker();
coffee.make();

const tea = new TeaMaker();
tea.make();

Visitor

The Visitor pattern lets you define new operations on objects without changing their classes.

❌ Without pattern

// Adding new operations requires modifying the classes
interface Animal {
    name: string;
}

class Dog implements Animal {
    name = "Dog";
    // If I want to add an operation, I must modify this class
}

class Cat implements Animal {
    name = "Cat";
    // And this one too
}

function makeSound(animal: Animal) {
    if (animal instanceof Dog) {
        console.log("Woof");
    } else if (animal instanceof Cat) {
        console.log("Meow");
    }
    // Adding a new animal requires modifying this function
}

✅ With Visitor pattern

interface Animal {
    accept(visitor: AnimalVisitor): void;
}

interface AnimalVisitor {
    visitDog(dog: Dog): void;
    visitCat(cat: Cat): void;
}

class Dog implements Animal {
    name = "Dog";

    accept(visitor: AnimalVisitor) {
        visitor.visitDog(this);
    }
}

class Cat implements Animal {
    name = "Cat";

    accept(visitor: AnimalVisitor) {
        visitor.visitCat(this);
    }
}

class SoundVisitor implements AnimalVisitor {
    visitDog(dog: Dog) {
        console.log("Woof");
    }

    visitCat(cat: Cat) {
        console.log("Meow");
    }
}

class FeedVisitor implements AnimalVisitor {
    visitDog(dog: Dog) {
        console.log("Feeding dog with bones");
    }

    visitCat(cat: Cat) {
        console.log("Feeding cat with fish");
    }
}

// Add new operations without modifying Animal classes
const dog = new Dog();
const cat = new Cat();

const soundVisitor = new SoundVisitor();
dog.accept(soundVisitor); // Woof
cat.accept(soundVisitor); // Meow

const feedVisitor = new FeedVisitor();
dog.accept(feedVisitor); // Feeding dog with bones
cat.accept(feedVisitor); // Feeding cat with fish

When to Use Each Pattern

Creational Patterns

  • Builder: When an object has many optional parameters
  • Factory Method: When you need to decouple object creation from its use
  • Abstract Factory: When you need to create families of related objects
  • Prototype: When creating objects is expensive and you can clone existing instances
  • Singleton: When you need exactly one instance of a class
  • Factory Function: When you need simple, reusable creation logic

Structural Patterns

  • Adapter: When you need to make incompatible interfaces work together
  • Bridge: When you want to separate abstraction from implementation
  • Composite: When you need to treat individual objects and groups uniformly
  • Decorator: When you need to add behavior dynamically
  • Facade: When you want to simplify a complex interface
  • Flyweight: When you need to optimize memory by sharing common data
  • Proxy: When you need to control access to an object

Behavioral Patterns

  • Chain of Responsibility: When multiple objects can handle a request
  • Command: When you need to parameterize objects with operations
  • Iterator: When you need to access collection elements without exposing its structure
  • Mediator: When you want to reduce coupling between communicating objects
  • Memento: When you need to save and restore an object’s state
  • Observer: When a change in one object requires updating others
  • State: When an object’s behavior depends on its state
  • Strategy: When you have multiple algorithms and want to make them interchangeable
  • Template Method: When you have similar algorithms with steps that vary
  • Visitor: When you need to add operations without modifying classes

My personal perspective

Design patterns aren’t rules you must follow blindly. They’re tools you should have in your toolbox and use when they make sense.

I’ve seen projects that use patterns unnecessarily, adding complexity without benefit. I’ve seen projects that would benefit greatly from using patterns but don’t, resulting in coupled, hard-to-maintain code.

The key is understanding when a pattern solves a real problem you have, not applying patterns because “you should use them”. If you have an object with many optional parameters, Builder makes sense. If you need to decouple object creation, Factory Method makes sense. If you need to add behavior dynamically, Decorator makes sense.

TypeScript makes many patterns safer and more expressive. Static typing helps detect errors early and makes pattern interfaces clearer.

Don’t try to use all patterns in a single project. Use the ones that solve specific problems you have. And remember: patterns are a means to an end (maintainable and scalable code), not an end in themselves.

At the end of the day, design patterns are about writing code that’s easy to understand, modify, and extend. If a pattern makes your code harder to understand, don’t use it. If it makes your code clearer and more maintainable, use it.