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.