TypeScript Design Patterns

Design patterns are proven solutions in software development that help us write maintainable and extensible code.

TypeScript's type system enables many classic design patterns to be implemented in a type-safe manner.


SVG Diagram: Design Pattern Classification Background Title Design Pattern Classification Creational Creational Patterns Factory | Singleton | Builder Structural Structural Patterns Decorator | Adapter | Proxy Behavioral Behavioral Patterns Observer | Strategy | Command TypeScript-specific TypeScript-specific Patterns Type-safe dependency injection | Generic factory | Conditional type selection Advantages TypeScript advantages: compile-time type checking + IDE IntelliSense + runtime performance

Why Do We Need Design Patterns?

Design patterns are code organization experience summarized by predecessors, and can solve common software design problems.

Using design patterns makes code easier to understand and maintain, and also facilitates team collaboration.

TypeScript's type system makes these patterns more robust, and errors can be discovered at compile time.

Concept:Design patterns are reusable solutions to common problems in software design, and are a summary of code design experience.


Singleton Pattern (Singleton)

Ensure a class has only one instance and provide a global access point.

Example

// Singleton pattern: ensure only one instance
class Singleton {
    // Store the singleton instance
    private static instance: Singleton;
    private static _data: string = "";

    // Private constructor to prevent external instantiation
    private constructor() {}

    // Static method to get the singleton instance
    public static getInstance(): Singleton {
        if (!Singleton.instance) {
            Singleton.instance = new Singleton();
        }
        return Singleton.instance;
    }

    // Set data
    public setData(data: string): void {
        Singleton._data = data;
    }

    // Get data
    public getData(): string {
        return Singleton._data;
    }
}

// Test the singleton pattern
const instance1 = Singleton.getInstance();
const instance2 = Singleton.getInstance();

// Verify it is the same instance
console.log("Is same instance: " + (instance1 === instance2));

instance1.setData("Hello Singleton");
console.log("Data: " + instance2.getData());

Output:

是同一实例: true
数据: Hello Singleton

Private constructor:By setting the constructor to private, it prevents external instantiation using `new`.


Factory Pattern (Factory)

Use a generic factory to create type-safe object instances.

Example

// Define product interface
interface Product {
    name: string;
    price: number;
    getDescription(): string;
}

// Concrete product: electronics
class ElectronicProduct implements Product {
    constructor(
        public name: string,
        public price: number,
        public warranty: number
    ) {}

    getDescription(): string {
        return `${this.name} - ¥${this.price} (warranty${this.warranty}year)`;
    }
}

// Concrete product: clothing
class ClothingProduct implements Product {
    constructor(
        public name: string,
        public price: number,
        public size: string
    ) {}

    getDescription(): string {
        return `${this.name} - ¥${this.price} (size: ${this.size})`;
    }
}

// Factory class
class ProductFactory {
    // Generic factory method
    static create<T extends Product>(
        type: new (...args: any[]) => T,
        ...args: any[]
    ): T {
        return new type(...args);
    }
}

// Use the factory to create products
const laptop = ProductFactory.create(ElectronicProduct, Laptop, 5999, 2);
const shirt = ProductFactory.create(ClothingProduct, T-shirt, 199, "L");

console.log(laptop.getDescription());
console.log(shirt.getDescription());

Generic factory:Use generic constraints to ensure a concrete product type is returned.


Decorator Pattern (Decorator)

Use decorators to dynamically add functionality to objects.

Example

// Basic coffee interface
interface Coffee {
    getCost(): number;
    getDescription(): string;
}

// Basic coffee implementation
class SimpleCoffee implements Coffee {
    getCost(): number {
        return 10;
    }

    getDescription(): string {
        return Coffee;
    }
}

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

    getCost(): number {
        return this.coffee.getCost();
    }

    getDescription(): string {
        return this.coffee.getDescription();
    }
}

// Milk decorator
class MilkDecorator extends CoffeeDecorator {
    getCost(): number {
        return this.coffee.getCost() + 2;
    }

    getDescription(): string {
        return this.coffee.getDescription() + ", Milk";
    }
}

// Sugar decorator
class SugarDecorator extends CoffeeDecorator {
    getCost(): number {
        return this.coffee.getCost() + 1;
    }

    getDescription(): string {
        return this.coffee.getDescription() + ", Sugar";
    }
}

// Use decorator
let coffee: Coffee = new SimpleCoffee();
console.log(coffee.getDescription() + " - ¥" + coffee.getCost());

coffee = new MilkDecorator(coffee);
console.log(coffee.getDescription() + " - ¥" + coffee.getCost());

coffee = new SugarDecorator(coffee);
console.log(coffee.getDescription() + " - ¥" + coffee.getCost());

Decorator:It can dynamically add new functionality without modifying the original class, and is an experimental feature of TypeScript.


Observer Pattern (Observer)

Define a one-to-many dependency between objects; when an object's state changes, all dependents are notified.

Example

// Observer interface
interface Observer {
    update(message: string): void;
}

// Subject interface
interface Subject {
    attach(observer: Observer): void;
    detach(observer: Observer): void;
    notify(): void;
}

// Concrete subject: message center
class MessageCenter implements Subject {
    private observers: Observer[] = [];
    private message: string = "";

    // Add observer
    attach(observer: Observer): void {
        this.observers.push(observer);
    }

    // Remove observer
    detach(observer: Observer): void {
        const index = this.observers.indexOf(observer);
        if (index > -1) {
            this.observers.splice(index, 1);
        }
    }

    // Notify all observers
    notify(): void {
        for (const observer of this.observers) {
            observer.update(this.message);
        }
    }

    // Publish message
    publish(message: string): void {
        this.message = message;
        console.log("Publish message: " + message);
        this.notify();
    }
}

// Concrete observer: user
class UserObserver implements Observer {
    constructor(public name: string) {}

    update(message: string): void {
        console.log(`[${this.name}]receivesMessage: ${message}`);
    }
}

// Use the observer pattern
const center = new MessageCenter();

const user1 = new UserObserver("User A");
const user2 = new UserObserver("User B");

center.attach(user1);
center.attach(user2);

center.publish("New feature is online!");

Decoupling:The observer pattern implements loose coupling between subjects and observers.


Strategy Pattern (Strategy)

Define a series of algorithms, encapsulate them one by one, and make them interchangeable.

Example

// Payment strategy interface
interface PaymentStrategy {
    pay(amount: number): void;
}

// WeChat Pay strategy
class WechatPayStrategy implements PaymentStrategy {
    pay(amount: number): void {
        console.log(`Using WeChat Pay ¥${amount}`);
    }
}

// Alipay strategy
class AlipayStrategy implements PaymentStrategy {
    pay(amount: number): void {
        console.log(`Using Alipay ¥${amount}`);
    }
}

// Bank card strategy
class CardPayStrategy implements PaymentStrategy {
    pay(amount: number): void {
        console.log(`Using Bank Card ¥${amount}`);
    }
}

// Payment context
class PaymentContext {
    private strategy: PaymentStrategy;

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

    // Set payment strategy
    setStrategy(strategy: PaymentStrategy): void {
        this.strategy = strategy;
    }

    // Execute payment
    pay(amount: number): void {
        this.strategy.pay(amount);
    }
}

// Use the strategy pattern
const context = new PaymentContext(new WechatPayStrategy());
context.pay(100);

context.setStrategy(new AlipayStrategy());
context.pay(200);

context.setStrategy(new CardPayStrategy());
context.pay(300);

Algorithm switching:The strategy pattern can switch algorithms at runtime, providing great flexibility.


Dependency Injection

Inject dependencies through the constructor; it is one of the most commonly used patterns in TypeScript.

Example

// Define service interface
interface Logger {
    log(message: string): void;
}

interface Storage {
    save(key: string, data: any): void;
}

// Concrete service implementation
class ConsoleLogger implements Logger {
    log(message: string): void {
        console.log("[Log]: " + message);
    }
}

class LocalStorage implements Storage {
    save(key: string, data: any): void {
        console.log(`Save ${key}: ${JSON.stringify(data)}`);
        localStorage.setItem(key, JSON.stringify(data));
    }
}

// Service using dependency injection
class UserService {
    constructor(
        private logger: Logger,
        private storage: Storage
    ) {}

    createUser(name: string): void {
        const user = { name, createdAt: new Date() };
        this.logger.log("Create user: " + name);
        this.storage.save("user", user);
    }
}

// Inject dependency
const logger = new ConsoleLogger();
const storage = new LocalStorage();
const userService = new UserService(logger, storage);

userService.createUser("Alice");

Dependency inversion:Dependency injection ensures that high-level modules do not depend on low-level modules, but rather on abstract interfaces.


Builder Pattern (Builder)

Separate the construction of a complex object from its representation, so that the same construction process can create different representations.

Example

// Builder interface
interface Builder<T> {
    build(): T;
}

// Complex object: user config
interface UserConfig {
    name: string;
    email: string;
    age?: number;
    role?: string;
    theme?: string;
}

// User config builder
class UserConfigBuilder implements Builder<UserConfig> {
    private config: Partial<UserConfig> = {};

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

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

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

    setRole(role: string): this {
        this.config.role = role;
        return this;
    }

    setTheme(theme: string): this {
        this.config.theme = theme;
        return this;
    }

    build(): UserConfig {
        if (!this.config.name || !this.config.email) {
            throw new Error("Name and email are required");
        }
        return this.config as UserConfig;
    }
}

// Use the builder
const builder = new UserConfigBuilder();
const config = builder
    .setName("Alice")
    .setEmail("[email protected]")
    .setAge(25)
    .setRole("admin")
    .setTheme("dark")
    .build();

console.log("User config:", JSON.stringify(config, null, 2));

Chained calls:The builder pattern supports chained calls, making code more concise and readable.


Notes

  • Type safety:Use TypeScript's type system to ensure the safety of pattern implementations.
  • Don't overdo it:Only use design patterns when truly needed.
  • Keep it simple:Prefer simple solutions.
  • Team consensus:Unify the use of design patterns within the team.

Best practice:Design patterns are tools, not dogma. Choose the pattern that fits the actual situation of the project.


Summary

Design patterns are important experience in code organization.

  • Singleton pattern:Ensure a class has only one instance
  • Factory pattern:Encapsulate the object creation process
  • Decorator pattern:Dynamically add functionality
  • Observer pattern:One-to-many dependency relationship
  • Strategy pattern:Algorithms are replaceable
  • Dependency injection:Decouple dependencies

Suggestion:Understanding and applying design patterns in practice can help you write more elegant and maintainable code.

Other Extensions