← Technology Archive

Historical archive

Common Design Patterns in TypeScript

TypeScript examples of the singleton, factory, proxy, observer, and strategy patterns, with notes on when each pattern is useful.

Singleton

Use a singleton when an application should initialize one shared instance rather than create several independent instances. Examples include a WebSocket connection manager or a global sound controller.

class WebSocketController {
  private static instance: WebSocketController

  // Prevent callers from constructing an instance directly.
  private constructor() {}

  static getInstance() {
    if (!WebSocketController.instance) {
      WebSocketController.instance = new WebSocketController()
    }
    return WebSocketController.instance
  }
}

const socket = WebSocketController.getInstance()

Factory

A factory encapsulates object creation so callers choose a product type without knowing the construction details.

enum CarType {
  BMW,
  Audi,
  Benz,
}

class Car {}
class Bmw extends Car {}
class Audi extends Car {}
class Benz extends Car {}

class CarFactory {
  static create(type: CarType): Car {
    switch (type) {
      case CarType.BMW:
        return new Bmw()
      case CarType.Audi:
        return new Audi()
      case CarType.Benz:
        return new Benz()
    }
  }
}

const bmw = CarFactory.create(CarType.BMW)

Proxy

A proxy adds control or behavior around a target without changing the target object. This separates cross-cutting behavior such as logging, authorization, or reactive tracking.

Proxy and decorator patterns both wrap behavior. A decorator primarily extends or enhances an object, while a proxy emphasizes access, indirection, or control. Vue 3 uses JavaScript proxies in its reactivity system.

class Person {
  constructor(public name: string) {}

  doSomething() {
    console.log('do something')
  }
}

const person = new Proxy(new Person('Stu'), {
  get(target, property) {
    console.log('Run an additional proxy task')
    return Reflect.get(target, property)
  },
})

person.doSomething()

Observer

When one object’s state changes, an observer pattern notifies all registered dependents. This is a one-to-many relationship.

interface Message {
  event: string
  payload: string
}

interface StateObserver {
  update(message: Message): void
}

class Person {
  private observers: StateObserver[] = []
  private currentState: Record<string, string> = {}

  addObserver(observer: StateObserver) {
    this.observers.push(observer)
  }

  removeObserver(observer: StateObserver) {
    const index = this.observers.indexOf(observer)
    if (index > -1) {
      this.observers.splice(index, 1)
    }
  }

  private notifyObservers(message: Message) {
    this.observers.forEach(observer => observer.update(message))
  }

  setState(event: string, payload: string) {
    this.currentState[event] = payload
    this.notifyObservers({ event, payload })
  }

  get state() {
    return this.currentState
  }
}

class ConsoleObserver implements StateObserver {
  constructor(private name: string) {}

  update(message: Message) {
    console.log(
      `${this.name} saw ${message.event} change to ${message.payload}`
    )
  }
}

const person = new Person()
const observer1 = new ConsoleObserver('Observer 1')
const observer2 = new ConsoleObserver('Observer 2')
const observer3 = new ConsoleObserver('Observer 3')

person.addObserver(observer1)
person.addObserver(observer2)
person.addObserver(observer3)
person.setState('weather', 'cloudy')

person.removeObserver(observer2)
person.setState('weather', 'light rain')

Strategy

The strategy pattern separates an algorithm from the context that uses it. A caller can switch strategies freely, avoid a large conditional, and add a new strategy by implementing the same interface.

interface Strategy {
  calculate(left: number, right: number): number
}

class AddStrategy implements Strategy {
  calculate(left: number, right: number) {
    return left + right
  }
}

class SubtractStrategy implements Strategy {
  calculate(left: number, right: number) {
    return left - right
  }
}

class Calculator {
  private strategy: Strategy

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

  calculate(left: number, right: number) {
    return this.strategy.calculate(left, right)
  }
}

const calculator = new Calculator()
calculator.setStrategy(new AddStrategy())
console.log(calculator.calculate(3, 4)) // 7

calculator.setStrategy(new SubtractStrategy())
console.log(calculator.calculate(3, 4)) // -1