OOP Design Patterns Implemented using Typescript

Share with a friend:

Design patterns play a crucial role in software development, providing proven solutions to common problems and promoting code organization and maintainability. When it comes to frontend development, TypeScript has emerged as a powerful tool, enhancing JavaScript with static typing and enabling developers to create robust and scalable applications. In this article, we will explore how to implement common OOP design patterns with Typescript.

Introduction to Design Patterns and TypeScript

Design patterns are reusable solutions to recurring problems in software design. They help developers avoid reinventing the wheel and adhere to best practices. TypeScript, a statically-typed superset of JavaScript, provides enhanced type checking and tooling support, making it an excellent choice for frontend development. TypeScript’s type system aids in catching errors early in the development process and improving code quality.

For an in-depth guide on Typescript visit here.

Let’s dive into some popular design patterns and see how they can be implemented with TypeScript.

Creational Patterns

Factory Design Pattern

The Factory Method pattern provides an interface for creating objects but allows subclasses to alter the type of objects that will be created. This pattern is particularly useful for creating instances of different classes based on a common interface or abstract class.

The main idea behind the Factory Design Pattern is to delegate the responsibility of creating objects to specialized classes called factories, rather than creating objects directly using constructors. The advantage of this is decoupling – making things less dependent on each other. Also, by centralizing the object creation logic in factories, you can manage object creation and configuration in one place, making maintenance and modifications easier.

interface Shape {
    draw(): void;
}

class Circle implements Shape {
    draw() {
        console.log("Drawing a circle");
    }
}

class Square implements Shape {
    draw() {
        console.log("Drawing a square");
    }
}

abstract class ShapeFactory {
    abstract createShape(): Shape;
}

class CircleFactory extends ShapeFactory {
    createShape() {
        return new Circle();
    }
}

class SquareFactory extends ShapeFactory {
    createShape() {
        return new Square();
    }
}

Singleton Design Pattern

The Singleton pattern ensures that a class has only one instance and provides a global point of access to that instance. TypeScript’s static type checking can prevent accidental creation of multiple instances.

The Singleton pattern is handy when you have a class that should have only one instance across your program. For example, a configuration manager, a logging system, or a database connection manager. These are things that you want to ensure are consistent and shared across different parts of your application.

class AppConfig {
    private static instance: AppConfig;

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

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

Builder Design Pattern

The Builder pattern separates the construction of a complex object from its representation, allowing the same construction process to create different representations. TypeScript’s optional properties and type-checking can assist in creating well-defined builders.

The Builder pattern helps you create complex objects step by step. It separates the construction of a complex object from its representation, allowing you to create different variations of the same object without cluttering your code with tons of constructors or initialization methods.

class Product {
    constructor(
        public name: string,
        public price: number,
        public description: string
    ) {}
}

class ProductBuilder {
    private name: string = "";
    private price: number = 0;
    private description: string = "";

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

    withPrice(price: number) {
        this.price = price;
        return this;
    }

    withDescription(description: string) {
        this.description = description;
        return this;
    }

    build(): Product {
        return new Product(this.name, this.price, this.description);
    }
}

// Using the ProductBuilder to create a Product instance
const product = new ProductBuilder()
    .withName("Example Product")
    .withPrice(99.99)
    .withDescription("This is an example product.")
    .build();

console.log(product);
/*
Product {
  name: 'Example Product',
  price: 99.99,
  description: 'This is an example product.'
}
*/

What is Method Chaining?

Method chaining allows you to call multiple methods on an object one after another, without having to repeatedly reference the object. Each method returns the modified object, so you can immediately call another method on the result.

To enable method chaining, each method in the builder class should return this (the current instance of the builder) after performing the necessary operation. This allows the next method to be called on the same instance, and the chain continues.

Structural Patterns

Adapter Design Pattern

The Adapter pattern allows objects with incompatible interfaces to collaborate. TypeScript’s type system helps ensure compatibility between the adapter and the adapted.

The Adapter pattern allows you to integrate new functionality with existing systems without needing to change their code. It’s like having a language translator that makes two people with different languages communicate effectively.

// Adaptee: The existing MP3 player class
class Mp3Player {
  playMp3(filename: string): void {
    console.log(`Playing MP3 file: ${filename}`);
  }
}

// Target: The interface expected by the client
interface AudioPlayer {
  play(filename: string): void;
}

// Adapter: Adapter for playing FLAC files using the Mp3Player
class FlacAdapter implements AudioPlayer {
  private mp3Player: Mp3Player;

  constructor(mp3Player: Mp3Player) {
    this.mp3Player = mp3Player;
  }

  play(filename: string): void {
    console.log(`Converting FLAC to MP3 and playing: ${filename}`);
    const convertedFilename = this.convertFlacToMp3(filename);
    this.mp3Player.playMp3(convertedFilename);
  }

  private convertFlacToMp3(filename: string): string {
    // Simulate the conversion process
    const convertedFilename = filename.replace('.flac', '.mp3');
    console.log(`Converted FLAC to MP3: ${convertedFilename}`);
    return convertedFilename;
  }
}

// Client code
const mp3Player = new Mp3Player();
const flacAdapter = new FlacAdapter(mp3Player);

const audioFiles: string[] = ['song1.mp3', 'song2.flac', 'song3.mp3'];

audioFiles.forEach(file => {
  if (file.endsWith('.mp3')) {
    mp3Player.playMp3(file);
  } else if (file.endsWith('.flac')) {
    flacAdapter.play(file);
  } else {
    console.log(`Unsupported audio format: ${file}`);
  }
});

Decorator Design Pattern

The Decorator pattern attaches additional responsibilities to an object dynamically. TypeScript’s interfaces and classes can be easily extended to implement decorators.

In OOP, the Decorator pattern lets you add new behaviors or features to objects without changing their underlying structure.

In this example, the SimpleCoffee class represents a basic coffee. The MilkDecorator and SugarDecorator classes both implement the Coffee interface and take an instance of another Coffee object as a constructor parameter. They enhance the behavior of the coffee by adding milk or sugar, respectively.

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

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

    description() {
        return "Simple coffee";
    }
}

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

    cost() {
        return this.coffee.cost() + 2;
    }

    description() {
        return `${this.coffee.description()} with milk`;
    }
}

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

    cost() {
        return this.coffee.cost() + 1;
    }

    description() {
        return `${this.coffee.description()} with sugar`;
    }
}

Composite Design Pattern

The Composite pattern composes objects into tree structures to represent part-whole hierarchies. TypeScript’s support for classes and inheritance simplifies the implementation of composite objects.

// Component
interface Employee {
  getName(): string;
  getRole(): string;
}

// Leaf
class Developer implements Employee {
  constructor(private name: string) {}

  getName(): string {
    return this.name;
  }

  getRole(): string {
    return 'Developer';
  }
}

// Composite
class Manager implements Employee {
  private team: Employee[] = [];

  constructor(private name: string) {}

  addEmployee(employee: Employee) {
    this.team.push(employee);
  }

  getName(): string {
    return this.name;
  }

  getRole(): string {
    return 'Manager';
  }

  getTeamInfo(): string {
    let info = `${this.name} - Manager\n`;
    for (const employee of this.team) {
      info += `  ${employee.getName()} - ${employee.getRole()}\n`;
    }
    return info;
  }
}

// Client code
const john = new Developer('John');
const sara = new Developer('Sara');

const engineeringManager = new Manager('Engineering Manager');
engineeringManager.addEmployee(john);
engineeringManager.addEmployee(sara);

const ceo = new Manager('CEO');
ceo.addEmployee(engineeringManager);

console.log(ceo.getTeamInfo());
/*
CEO - Manager
  Engineering Manager - Manager
*/

console.log(engineeringManager.getTeamInfo());
/*
Engineering Manager - Manager
  John - Developer
  Sara - Developer
*/

Behavioral Patterns

Observer Design Pattern

The Observer pattern defines a dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. TypeScript’s type system helps ensure that observers and subjects are properly synchronized.

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

class ConcreteObserver implements Observer {
    update(data: any) {
        console.log(`Received update: ${data}`);
    }
}

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

class ConcreteSubject implements Subject {
    private observers: Observer[] = [];
    private data: any;

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

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

    notify() {
        for (const observer of this.observers) {
            observer.update(this.data);
        }
    }

    setData(data: any) {
        this.data = data;
        this.notify();
    }
}

If you are an experienced Javascript developer then you have probably used the Observer pattern without knowing. For instance, JavaScript’s built-in event handling mechanism, such as the addEventListener function, is an implementation of the Observer pattern. DOM elements act as subjects, and event listeners act as observers. When an event occurs on the subject (e.g., a button click), all registered event listeners are notified and invoked.

Other examples include State Management in React using the Context API and the Intersection Observer API.


Strategy Design Pattern

The Strategy pattern defines a family of interchangeable algorithms and makes them easily swappable. TypeScript’s interfaces and type-checking contribute to a seamless integration of strategies.

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

class CreditCardPayment implements PaymentStrategy {
    constructor(private cardNumber: string) {}

    pay(amount: number) {
        console.log(`Paid ${amount} using credit card ${this.cardNumber}`);
    }
}

class PayPalPayment implements PaymentStrategy {
    constructor(private email: string) {}

    pay(amount: number) {
        console.log(`Paid ${amount} using PayPal account ${this.email}`);
    }
}

class PaymentContext {
    private paymentStrategy: PaymentStrategy;

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

    makePayment(amount: number) {
        this.paymentStrategy.pay(amount);
    }
}

// Client code
const creditCardPayment = new CreditCardPayment('1234-5678-9012-3456');
const paypalPayment = new PayPalPayment('[email protected]');

const paymentContextCreditCard = new PaymentContext(creditCardPayment);
const paymentContextPayPal = new PaymentContext(paypalPayment);

paymentContextCreditCard.makePayment(100);
paymentContextPayPal.makePayment(50);

State Design Pattern

The State pattern allows an object to change its behavior when its internal state changes. This is achieved by separating the states into separate classes and allowing the object to switch between these states as needed.

TypeScript’s classes and type-checking help ensure that states and context classes are consistent.

In this example, the State Pattern is demonstrated using a simple example of a file that can be in either an “Open” state or a “Closed” state.

interface State {
    handleRequest(): void;
}

class OpenState implements State {
    handleRequest() {
        console.log("File is open");
    }
}

class ClosedState implements State {
    handleRequest() {
        console.log("File is closed");
    }
}

class FileContext {
    private state: State;

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

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

    request() {
        this.state.handleRequest();
    }
}

// Client code

const fileContext = new FileContext();

fileContext.request(); // Output: File is closed

fileContext.setState(new OpenState());
fileContext.request(); // Output: File is open

fileContext.setState(new ClosedState());
fileContext.request(); // Output: File is closed

Benefits of Implementing OOP Design Patterns with Typescript

TypeScript enhances the implementation of design patterns in several ways:

  1. Type Safety: TypeScript’s static type checking prevents many common errors during development and refactoring, ensuring that patterns are implemented correctly.
  2. Code Readability: The explicit typing in TypeScript code improves readability, making patterns and their interactions more understandable.
  3. Tooling Support: TypeScript’s tooling, such as autocompletion and type inference, speeds up development when working with pattern-related classes and interfaces.
  4. Refactoring Confidence: Renaming classes, methods, or properties is less error-prone in TypeScript due to its type-aware nature.

Conclusion

Design patterns provide a systematic approach to solving common software design problems. Creational, structural, and behavioral patterns offer elegant solutions for creating objects, managing their relationships, and defining their interactions.

Share with a friend:

Rajae Robinson

Rajae Robinson is a young Software Developer with over 3 years of work experience building websites and mobile apps. He has extensive experience with React.js and Next.js.

More Posts on Typescript