TypeScript Design Mastery: Patterns Unlocked

TypeScript Design Mastery: Patterns Unlocked

Design patterns are tried and tested solutions to common problems in software design. In TypeScript, design patterns help create maintainable, reusable, and scalable applications by leveraging modern features like types, interfaces, and classes. In this post, we will cover some of the most commonly used design patterns in TypeScript: Singleton, Factory, Observer, Strategy, and Decorator. Each pattern will be explained with code examples, advantages, disadvantages, and real-world use cases.

1. Singleton Pattern

The Singleton pattern restricts the instantiation of a class to a single instance, providing a global point of access.

Code Example

class Singleton {
  private static instance: Singleton;
  
  private constructor() {}

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

  public log(): void {
    console.log("Logging from the Singleton instance");
  }
}

// Usage
const singleton1 = Singleton.getInstance();
const singleton2 = Singleton.getInstance();

console.log(singleton1 === singleton2); // true        

Real-World Example: Global Configuration

Consider an application where you need a global configuration object accessible throughout the app without creating multiple instances.

class Config {
  private static instance: Config;
  public apiUrl: string;

  private constructor() {
    this.apiUrl = "https://api.example.com";
  }

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

// Usage
const config1 = Config.getInstance();
const config2 = Config.getInstance();

console.log(config1.apiUrl); // https://api.example.com
console.log(config1 === config2); // true        

2. Factory Pattern

The Factory pattern provides an interface for creating objects but allows subclasses to alter the type of objects that will be created.

Code Example

interface Animal {
  speak(): void;
}

class Dog implements Animal {
  speak(): void {
    console.log("Woof!");
  }
}

class Cat implements Animal {
  speak(): void {
    console.log("Meow!");
  }
}

class AnimalFactory {
  static createAnimal(type: string): Animal {
    switch(type) {
      case 'dog': return new Dog();
      case 'cat': return new Cat();
      default: throw new Error("Invalid animal type");
    }
  }
}

// Usage
const dog = AnimalFactory.createAnimal("dog");
dog.speak(); // Woof!        

Real-World Example: User Role Factory

In an authentication system, you may need to create different user roles (Admin, Guest, Member), each with different permissions.

interface User {
  getPermissions(): string[];
}

class Admin implements User {
  getPermissions(): string[] {
    return ["create", "read", "update", "delete"];
  }
}

class Guest implements User {
  getPermissions(): string[] {
    return ["read"];
  }
}

class Member implements User {
  getPermissions(): string[] {
    return ["read", "update"];
  }
}

class UserFactory {
  static createUser(role: string): User {
    switch (role) {
      case 'admin': return new Admin();
      case 'guest': return new Guest();
      case 'member': return new Member();
      default: throw new Error("Invalid role");
    }
  }
}

// Usage
const admin = UserFactory.createUser("admin");
console.log(admin.getPermissions()); // ["create", "read", "update", "delete"]        

3. Observer Pattern

The Observer pattern defines a subscription mechanism to notify multiple objects about any state changes.

Code Example

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

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

  removeObserver(observer: Observer): void {
    this.observers = this.observers.filter(obs => obs !== observer);
  }

  notifyObservers(): void {
    for (const observer of this.observers) {
      observer.update();
    }
  }
}

interface Observer {
  update(): void;
}

class ConcreteObserver implements Observer {
  update(): void {
    console.log("Observer notified");
  }
}

// Usage
const subject = new Subject();
const observer1 = new ConcreteObserver();
const observer2 = new ConcreteObserver();

subject.addObserver(observer1);
subject.addObserver(observer2);
subject.notifyObservers(); // Observer notified (twice)        

Real-World Example: Stock Market App

In a stock market app, different components may need to be notified of stock price changes.

class StockMarket {
  private observers: StockObserver[] = [];

  addObserver(observer: StockObserver): void {
    this.observers.push(observer);
  }

  removeObserver(observer: StockObserver): void {
    this.observers = this.observers.filter(obs => obs !== observer);
  }

  updatePrice(newPrice: number): void {
    console.log(`Stock price updated to ${newPrice}`);
    this.notifyObservers(newPrice);
  }

  private notifyObservers(newPrice: number): void {
    for (const observer of this.observers) {
      observer.update(newPrice);
    }
  }
}


interface StockObserver {
  update(price: number): void;
}

class Investor implements StockObserver {
  update(price: number): void {
    console.log(`Investor notified of new price: ${price}`);
  }
}

// Usage
const market = new StockMarket();
const investor1 = new Investor();
const investor2 = new Investor();

market.addObserver(investor1);
market.addObserver(investor2);

market.updatePrice(100); // Investor notified of new price: 100 (twice)        

4. Strategy Pattern

The Strategy pattern allows selecting an algorithm at runtime from a family of algorithms.

Code Example

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

class CreditCardPayment implements PaymentStrategy {
  pay(amount: number): void {
    console.log(`Paid ${amount} using credit card.`);
  }
}

class PayPalPayment implements PaymentStrategy {
  pay(amount: number): void {
    console.log(`Paid ${amount} using PayPal.`);
  }
}

class PaymentProcessor {
  constructor(private strategy: PaymentStrategy) {}

  processPayment(amount: number): void {
    this.strategy.pay(amount);
  }
}

// Usage
const creditCardPayment = new CreditCardPayment();
const paypalPayment = new PayPalPayment();

const paymentProcessor = new PaymentProcessor(creditCardPayment);
paymentProcessor.processPayment(100); // Paid 100 using credit card.

paymentProcessor = new PaymentProcessor(paypalPayment);
paymentProcessor.processPayment(200); // Paid 200 using PayPal.        

Real-World Example: File Compression

In an application, you can select different file compression algorithms at runtime, like ZIP or RAR.

interface CompressionStrategy {
  compress(files: string[]): void;
}

class ZipCompression implements CompressionStrategy {
  compress(files: string[]): void {
    console.log(`Compressing ${files.length} files using ZIP`);
  }
}

class RarCompression implements CompressionStrategy {
  compress(files: string[]): void {
    console.log(`Compressing ${files.length} files using RAR`);
  }
}

class CompressionContext {
  constructor(private strategy: CompressionStrategy) {}

  compressFiles(files: string[]): void {
    this.strategy.compress(files);
  }
}

// Usage
const zipCompression = new ZipCompression();
const rarCompression = new RarCompression();

const files = ["file1.txt", "file2.txt"];

const context = new CompressionContext(zipCompression);
context.compressFiles(files); // Compressing 2 files using ZIP

context = new CompressionContext(rarCompression);
context.compressFiles(files); // Compressing 2 files using RAR        

5. Decorator Pattern

The Decorator pattern allows adding new functionality to an object dynamically without altering its structure.

Code Example

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

class BasicCoffee implements Coffee {
  cost(): number {
    return 5;
  }
  description(): string {
    return "Basic Coffee";
  }
}

class MilkDecorator implements Coffee {
  constructor(private coffee: Coffee) {}

  cost(): number {
    return this.coffee.cost() + 2;
  }
  
  description(): string {
    return `${this.coffee.description()}, with milk`;
  }
}

class SugarDecorator implements Coffee {
  constructor(private coffee: Coffee) {}

  cost(): number {
    return this.coffee.cost() + 1;
  }
  
  description(): string {
    return `${this.coffee.description()}, with sugar`;
  }
}

// Usage
let myCoffee: Coffee = new BasicCoffee();
myCoffee = new MilkDecorator(myCoffee);
myCoffee = new SugarDecorator(myCoffee);

console.log(myCoffee.description()); // Basic Coffee, with milk, with sugar
console.log(myCoffee.cost()); // 8        

Real-World Example: API Request Logging

In an API service, you may need to add additional functionality, such as logging, without modifying the core request logic. typescript

interface ApiService {
  fetchData(): void;
}

class BasicApiService implements ApiService {
  fetchData(): void {
    console.log("Fetching data from API");
  }
}

class LoggingDecorator implements ApiService {
  constructor(private service: ApiService) {}

  fetchData(): void {
    console.log("Logging API request");
    this.service.fetchData();
  }
}

class ErrorHandlingDecorator implements ApiService {
  constructor(private service: ApiService) {}

  fetchData(): void {
    try {
      this.service.fetchData();
    } catch (error) {
      console.error("Error occurred during API request");
    }
  }
}

// Usage
let apiService: ApiService = new BasicApiService();
apiService = new LoggingDecorator(apiService);
apiService = new ErrorHandlingDecorator(apiService);

apiService.fetchData();
// Logging API request
// Fetching data from API        

In this example, we are enhancing the functionality of a basic API service by adding logging and error-handling features using decorators, without modifying the core service.

How to Choose a Pattern Effectively

Choosing the right design pattern depends on several factors:

Identify the Problem:

  • Determine the exact problem you’re trying to solve. Is it related to object creation, behavior extension, or complex interactions between objects?
  • If your problem is related to object creation, use patterns like Factory or Singleton.
  • If you need behavior modification, consider Strategy or Decorator.
  • For managing state changes between different components, the Observer pattern is ideal.

Simplicity vs. Flexibility:

  • Singleton is simple and efficient but can limit flexibility.
  • Factory adds complexity but provides flexibility in creating objects.
  • Strategy and Decorator patterns promote flexibility by allowing runtime behavior changes, but they increase the number of classes and interfaces.

Code Maintainability:

  • Patterns like Factory and Strategy enhance maintainability in large projects by encapsulating logic in separate, easy-to-modify classes.
  • On the other hand, patterns like Singleton can make unit testing and mocking more challenging.

Performance Considerations:

  • Some patterns may introduce performance overhead due to the creation of extra objects or complex interactions.
  • For instance, the Observer pattern may slow down the system if too many observers are registered, and excessive use of decorators can increase the complexity of object structures.

Team and Project Size:

  • For small projects or teams, simplicity should be prioritized, using patterns only when necessary.
  • In contrast, larger, scalable applications benefit from patterns that improve modularity, reusability, and flexibility.

Conclusion

Design patterns are powerful tools for creating scalable, maintainable, and efficient TypeScript applications. While each pattern has its advantages and disadvantages, understanding when and how to apply them is crucial for solving specific design challenges. Whether it’s handling global state with the Singleton pattern or allowing dynamic behavior changes with the Strategy or Decorator patterns, selecting the right design pattern can significantly improve the quality of your code.

However, avoid over-engineering — use patterns only when they provide clear benefits. Start by identifying your problem, weighing the pros and cons of each pattern, and considering the scalability and flexibility of your application.

要查看或添加评论,请登录

Sehban Alam的更多文章

社区洞察

其他会员也浏览了