Software Design Patterns in javascript/typescript

blog-content-img

Design patterns are solutions to common problems in software design. They represent best practices used by experienced object-oriented software developers.

What are Design Problems?

In software development, certain problems recur frequently, regardless of the application domain or programming language used. These problems, known as design problems, often arise from the complexities of building scalable, maintainable, and reusable software systems. Common design problems include:

Managing Object Creation: Creating objects in a way that is independent of the specific class and ensuring that the creation process is flexible and extensible.

Structuring Code for Scalability: Designing the system architecture to handle future growth and changes without significant rewrites.

Encapsulating Behavior: Encapsulating varying behavior and algorithms to promote code reuse and reduce dependencies.

Decoupling Components: Reducing the dependencies between different parts of a system to make it easier to change and maintain.

Optimizing Performance: Managing resources efficiently to optimize performance and minimize memory usage.

Why Define Solutions for These Problems?

Defining solutions for these recurring design problems helps developers:

Improve Code Quality: Using well-established solutions leads to cleaner, more readable, and maintainable code.

Enhance Reusability: Common solutions can be reused across different projects, reducing development time and effort.

Promote Best Practices: Design patterns encapsulate best practices and provide a shared vocabulary for developers to communicate complex concepts effectively.

Facilitate Maintenance: Well-structured code is easier to debug, extend, and maintain, reducing the long-term cost of software projects.

Design patterns provide a catalog of proven solutions to these common problems. They offer templates for how to structure code to solve specific design challenges, making it easier to develop robust and flexible software systems.

Below are some of the most commonly used design patterns, explained with examples in TypeScript.

Flyweight Pattern

The Flyweight pattern is used to minimize memory usage or computational expenses by sharing as much data as possible with similar objects.

class Flyweight {
  constructor(private sharedState: string) {}

  operation(uniqueState: string) {
    console.log(
      `Flyweight: Displaying shared (${this.sharedState}) and unique (${uniqueState}) state.`
    );
  }
}

class FlyweightFactory {
  private flyweights: { [key: string]: Flyweight } = {};

  getFlyweight(sharedState: string): Flyweight {
    if (!(sharedState in this.flyweights)) {
      this.flyweights[sharedState] = new Flyweight(sharedState);
    }
    return this.flyweights[sharedState];
  }

  listFlyweights() {
    const count = Object.keys(this.flyweights).length;
    console.log(`FlyweightFactory: I have ${count} flyweights:`);
    for (const key in this.flyweights) {
      console.log(key);
    }
  }
}

const factory = new FlyweightFactory();
const flyweight1 = factory.getFlyweight("SharedState1");
flyweight1.operation("UniqueStateA");

const flyweight2 = factory.getFlyweight("SharedState1");
flyweight2.operation("UniqueStateB");

factory.listFlyweights();

Observer Pattern

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

interface Observer {
  update(subject: Subject): void;
}

class Subject {
  private observers: Observer[] = [];
  private state: number = 0;

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

  setState(state: number) {
    this.state = state;
    this.notifyAllObservers();
  }

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

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

class ConcreteObserver implements Observer {
  constructor(private name: string) {}

  update(subject: Subject) {
    console.log(`${this.name} received update: ${subject.getState()}`);
  }
}

const subject = new Subject();
const observer1 = new ConcreteObserver("Observer 1");
const observer2 = new ConcreteObserver("Observer 2");

subject.attach(observer1);
subject.attach(observer2);

subject.setState(1);
subject.setState(2);

Adapter Pattern

The Adapter pattern allows incompatible interfaces to work together. It acts as a bridge between two incompatible interfaces.

interface Target {
  request(): void;
}

class Adaptee {
  specificRequest(): void {
    console.log("Specific request in Adaptee.");
  }
}

class Adapter implements Target {
  private adaptee: Adaptee;

  constructor(adaptee: Adaptee) {
    this.adaptee = adaptee;
  }

  request(): void {
    this.adaptee.specificRequest();
  }
}

const adaptee = new Adaptee();
const adapter = new Adapter(adaptee);

adapter.request();

Decorator Pattern

The Decorator pattern allows behavior to be added to individual objects, dynamically, without affecting the behavior of other objects from the same class.

interface Component {
  operation(): string;
}

class ConcreteComponent implements Component {
  operation(): string {
    return "ConcreteComponent";
  }
}

class Decorator implements Component {
  protected component: Component;

  constructor(component: Component) {
    this.component = component;
  }

  operation(): string {
    return this.component.operation();
  }
}

class ConcreteDecoratorA extends Decorator {
  operation(): string {
    return `ConcreteDecoratorA(${super.operation()})`;
  }
}

class ConcreteDecoratorB extends Decorator {
  operation(): string {
    return `ConcreteDecoratorB(${super.operation()})`;
  }
}

const simple = new ConcreteComponent();

const decoratorA = new ConcreteDecoratorA(simple);

const decoratorB = new ConcreteDecoratorB(decoratorA);

Facade Pattern

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

class Subsystem1 {
  operation1(): string {
    return "Subsystem1: Ready!";
  }

  operationN(): string {
    return "Subsystem1: Go!";
  }
}

class Subsystem2 {
  operation1(): string {
    return "Subsystem2: Get ready!";
  }

  operationZ(): string {
    return "Subsystem2: Fire!";
  }
}

class Facade {
  protected subsystem1: Subsystem1;
  protected subsystem2: Subsystem2;

  constructor(subsystem1: Subsystem1, subsystem2: Subsystem2) {
    this.subsystem1 = subsystem1;
    this.subsystem2 = subsystem2;
  }

  operation(): string {
    let result = "Facade initializes subsystems:\n";
    result += this.subsystem1.operation1() + "\n";
    result += this.subsystem2.operation1() + "\n";
    result += "Facade orders subsystems to perform the action:\n";
    result += this.subsystem1.operationN() + "\n";
    result += this.subsystem2.operationZ();
    return result;
  }
}

const subsystem1 = new Subsystem1();
const subsystem2 = new Subsystem2();
const facade = new Facade(subsystem1, subsystem2);

Factory Pattern

The Factory pattern defines an interface for creating an object, but lets subclasses alter the type of objects that will be created.

abstract class Product {
  abstract operation(): string;
}

class ConcreteProductA extends Product {
  operation(): string {
    return "{Result of ConcreteProductA}";
  }
}

class ConcreteProductB extends Product {
  operation(): string {
    return "{Result of ConcreteProductB}";
  }
}

abstract class Creator {
  abstract factoryMethod(): Product;

  someOperation(): string {
    const product = this.factoryMethod();
    return `Creator: The same creator's code has just worked with ${product.operation()}`;
  }
}

class ConcreteCreatorA extends Creator {
  factoryMethod(): Product {
    return new ConcreteProductA();
  }
}

class ConcreteCreatorB extends Creator {
  factoryMethod(): Product {
    return new ConcreteProductB();
  }
}

const creatorA = new ConcreteCreatorA();
console.log(creatorA.someOperation());

const creatorB = new ConcreteCreatorB();
console.log(creatorB.someOperation());

Prototype Pattern

The Prototype pattern is used to create a new object by copying an existing object, known as the prototype.

interface Prototype {
  clone(): Prototype;
}

class ConcretePrototype1 implements Prototype {
  clone(): Prototype {
    return new ConcretePrototype1();
  }
}

class ConcretePrototype2 implements Prototype {
  clone(): Prototype {
    return new ConcretePrototype2();
  }
}

const prototype1 = new ConcretePrototype1();
const clone1 = prototype1.clone();

const prototype2 = new ConcretePrototype2();
const clone2 = prototype2.clone();

Singleton Pattern

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

class Singleton {
  private static instance: Singleton;

  private constructor() {}

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

  someBusinessLogic() {
    // ...
  }
}

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

Strategy Pattern

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

interface Strategy {
  doAlgorithm(data: string[]): string[];
}

class ConcreteStrategyA implements Strategy {
  doAlgorithm(data: string[]): string[] {
    return data.sort();
  }
}

class ConcreteStrategyB implements Strategy {
  doAlgorithm(data: string[]): string[] {
    return data.reverse();
  }
}

class Context {
  private strategy: Strategy;

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

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

  doSomeBusinessLogic() {
    const result = this.strategy.doAlgorithm(["a", "b", "c", "d", "e"]);
  }
}

const context = new Context(new ConcreteStrategyA());
context.doSomeBusinessLogic();

context.setStrategy(new ConcreteStrategyB());
context.doSomeBusinessLogic();

Wrap-up

Design patterns have proven to be extremely valuable in building robust, maintainable, and scalable software systems. These patterns address common design problems by providing well-established solutions that can be reused across different projects. Their effectiveness has been demonstrated in numerous successful software projects over the years.

Major product-based multinational corporations (MNCs) follow these patterns to ensure code maintainability, scalability, and flexibility. By adhering to these best practices, developers can create software that is easier to understand, extend, and maintain, ultimately leading to more successful and long-lasting projects.

Using design patterns is not just about solving specific problems but also about adopting a mindset that prioritizes good design principles and practices. As you continue to develop your skills, integrating these patterns into your workflow will help you produce higher quality software more efficiently.