TypeScript Design Patterns: A Comprehensive Guide

Lakin Mohapatra
7 min readJan 24, 2025

--

Design patterns are reusable solutions to common problems in software design. They provide a structured approach to writing clean, maintainable, and scalable code. TypeScript, with its strong typing system, makes it easier to implement these patterns effectively.

In this article, we will explore key design patterns in TypeScript, along with practical examples and use cases.

1. Singleton Pattern

Ensures a class has only one instance and provides a global point of access to it.

Use Cases

Database Connection:

  • Ensure only one instance of a database connection is created and reused across the application.
  • Example: A Database class that manages a single connection pool.

Configuration Management:

  • Maintain a single source of truth for application settings.
  • Example: A Config class that loads and provides access to configuration values.

Logger:

  • Use a single logger instance across the application to avoid creating multiple log files or streams.
  • Example: A Logger class that writes logs to a single file.

Implementation

class Database {
private static instance: Database;
private constructor() {}
public static getInstance(): Database {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
public query(sql: string): void {
console.log(`Executing query: ${sql}`);
}
}
const db1 = Database.getInstance();
const db2 = Database.getInstance();
console.log(db1 === db2); // true (same instance)

2. Factory Pattern

Creates objects without specifying the exact class of the object that will be created.

Use Case

UI Components:

  • Create different types of UI elements (e.g., buttons, modals) based on user input or configuration.
  • Example: A ButtonFactory that creates PrimaryButton, SecondaryButton, etc.

Payment Methods:

  • Instantiate different payment processors (e.g., credit card, PayPal) based on user selection.
  • Example: A PaymentFactory that creates CreditCardPayment, PayPalPayment, etc.

Game Characters:

  • Create different types of game characters (e.g., warriors, mages) dynamically.
  • Example: A CharacterFactory that creates Warrior, Mage, etc.

Implementation

interface Product {
operation(): string;
}

class ConcreteProductA implements Product {
operation(): string {
return "Product A";
}
}
class ConcreteProductB implements Product {
operation(): string {
return "Product B";
}
}
class ProductFactory {
public createProduct(type: string): Product {
switch (type) {
case "A":
return new ConcreteProductA();
case "B":
return new ConcreteProductB();
default:
throw new Error("Invalid product type");
}
}
}
const factory = new ProductFactory();
const productA = factory.createProduct("A");
console.log(productA.operation()); // "Product A"

3. Observer Pattern

Defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified.

Use Case

Event Handling:

  • Notify multiple components when an event occurs (e.g., button click, data update).
  • Example: A Button class that notifies all subscribed event listeners when clicked.

Real-Time Notifications:

  • Send real-time updates to users (e.g., chat messages, system alerts).
  • Example: A NotificationService that notifies all connected clients when a new message arrives.

State Management:

  • Update multiple UI components when the application state changes.
  • Example: A StateManager that notifies all subscribers when the state is updated.

Implementation

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

class ConcreteObserver implements Observer {
constructor(private name: string) {}
update(message: string): void {
console.log(`${this.name} received message: ${message}`);
}
}

class Subject {
private observers: Observer[] = [];
public addObserver(observer: Observer): void {
this.observers.push(observer);
}
public notifyObservers(message: string): void {
for (const observer of this.observers) {
observer.update(message);
}
}
}

const subject = new Subject();
const observer1 = new ConcreteObserver("Observer 1");
const observer2 = new ConcreteObserver("Observer 2");
subject.addObserver(observer1);
subject.addObserver(observer2);
subject.notifyObservers("Hello, observers!");
// Output:
// Observer 1 received message: Hello, observers!
// Observer 2 received message: Hello, observers!

4. Strategy Pattern

Defines a family of algorithms, encapsulates each one, and makes them interchangeable.

Use Case

Sorting Algorithms:

  • Switch between different sorting algorithms (e.g., bubble sort, quicksort) dynamically.
  • Example: A Sorter class that uses a SortStrategy to perform sorting.

Payment Methods:

  • Allow users to choose between different payment methods (e.g., credit card, PayPal).
  • Example: A PaymentProcessor that uses a PaymentStrategy to process payments.

Compression Algorithms:

  • Switch between different compression algorithms (e.g., ZIP, RAR) based on user preference.
  • Example: A Compressor class that uses a CompressionStrategy to compress files.

Implementation

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

class CreditCardPayment implements PaymentStrategy {
pay(amount: number): void {
console.log(`Paid ${amount} via Credit Card`);
}
}

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

class PaymentContext {
constructor(private strategy: PaymentStrategy) {}
public executePayment(amount: number): void {
this.strategy.pay(amount);
}
}

const creditCard = new CreditCardPayment();
const payPal = new PayPalPayment();
const context = new PaymentContext(creditCard);
context.executePayment(100); // Paid 100 via Credit Card
context.executePayment(200); // Paid 200 via PayPal

5. Decorator Pattern

Adds behavior to objects dynamically without affecting other objects.

Use Case

Logging:

  • Add logging functionality to methods without modifying their core logic.
  • Example: A LoggingDecorator that logs method calls and their results.

Caching:

  • Add caching to expensive operations (e.g., API calls, database queries).
  • Example: A CachingDecorator that caches the results of a method.

UI Enhancements:

  • Add additional features to UI components (e.g., borders, shadows) dynamically.
  • Example: A BorderDecorator that adds a border to a Button component.

Implementation

interface Component {
operation(): string;
}

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

class Decorator implements Component {
constructor(private component: Component) {}
operation(): string {
return this.component.operation();
}
}

class LoggingDecorator extends Decorator {
operation(): string {
console.log("Before operation");
const result = super.operation();
console.log("After operation");
return result;
}
}

const component = new ConcreteComponent();
const decoratedComponent = new LoggingDecorator(component);
console.log(decoratedComponent.operation());
// Output:
// Before operation
// After operation
// ConcreteComponent

6. Adapter Pattern

Allows incompatible interfaces to work together.

Use Case

Third-Party Libraries:

  • Integrate third-party libraries with incompatible interfaces into your application.
  • Example: An Adapter that makes a third-party logging library compatible with your application’s logging interface.

Legacy Code Integration:

  • Adapt legacy code to work with new systems or frameworks.
  • Example: An Adapter that allows a legacy database system to work with a modern ORM.

API Versioning:

  • Adapt an old API to work with a new version of the application.
  • Example: An Adapter that converts requests from an old API format to a new one.

Implementation

interface NewSystem {
request(): string;
}

class OldSystem {
specificRequest(): string {
return "Old System";
}
}

class Adapter implements NewSystem {
constructor(private oldSystem: OldSystem) {}
request(): string {
return this.oldSystem.specificRequest();
}
}

const oldSystem = new OldSystem();
const adapter = new Adapter(oldSystem);
console.log(adapter.request()); // "Old System"

7. Command Pattern

Encapsulates a request as an object, allowing parameterization and queuing of requests.

Use Case

Undo/Redo Functionality:

  • Implement undo/redo functionality in applications (e.g., text editors, graphic design tools).
  • Example: A Command that encapsulates text editing operations (e.g., AddTextCommand, DeleteTextCommand).

Remote Control Systems:

  • Control devices (e.g., lights, TVs) using a remote control.
  • Example: A RemoteControl that executes commands like TurnOnLightCommand, TurnOffTVCommand.

Task Scheduling:

  • Schedule and execute tasks at specific times or events.
  • Example: A Scheduler that executes SendEmailCommand at a specific time.

Implementation

interface Command {
execute(): void;
}

class Light {
on(): void {
console.log("Light is on");
}
off(): void {
console.log("Light is off");
}
}

class LightOnCommand implements Command {
constructor(private light: Light) {}
execute(): void {
this.light.on();
}
}

class LightOffCommand implements Command {
constructor(private light: Light) {}
execute(): void {
this.light.off();
}
}

class RemoteControl {
private command: Command;
setCommand(command: Command): void {
this.command = command;
}
pressButton(): void {
this.command.execute();
}
}

const light = new Light();
const lightOn = new LightOnCommand(light);
const lightOff = new LightOffCommand(light);
const remote = new RemoteControl();
remote.setCommand(lightOn);
remote.pressButton(); // Light is on
remote.setCommand(lightOff);
remote.pressButton(); // Light is off

8. Template Method Pattern

Defines the skeleton of an algorithm in a method, deferring some steps to subclasses.

Use Case

Workflow Frameworks:

  • Define a common workflow with customizable steps (e.g., order processing, user registration).
  • Example: An OrderProcessing class with steps like validateOrder, processPayment, shipOrder.

Game Development:

  • Define a common game loop with customizable steps (e.g., initialize, start, end).
  • Example: A Game class with steps like initialize, startPlay, endPlay.

Report Generation:

  • Generate reports with a common structure but customizable content.
  • Example: A ReportGenerator class with steps like fetchData, formatData, generateReport.

Implementation

abstract class Game {
abstract initialize(): void;
abstract startPlay(): void;
abstract endPlay(): void;

play(): void {
this.initialize();
this.startPlay();
this.endPlay();
}
}

class Football extends Game {
initialize(): void {
console.log("Football Game Initialized");
}
startPlay(): void {
console.log("Football Game Started");
}
endPlay(): void {
console.log("Football Game Ended");
}
}

const game = new Football();
game.play();
// Output:
// Football Game Initialized
// Football Game Started
// Football Game Ended

9. Proxy Pattern

Provides a surrogate or placeholder for another object to control access to it.

Use Case

Lazy Loading:

  • Load resources (e.g., images, data) only when needed.
  • Example: A ProxyImage that loads an image only when display is called.

Access Control:

  • Restrict access to sensitive resources (e.g., admin-only features).
  • Example: A ProtectedResourceProxy that checks user permissions before granting access.

Caching:

  • Cache expensive operations (e.g., API calls, database queries) to improve performance.
  • Example: A CachingProxy that caches the results of a method.

Implementation

interface Image {
display(): void;
}

class RealImage implements Image {
constructor(private filename: string) {
this.loadFromDisk();
}
private loadFromDisk(): void {
console.log(`Loading ${this.filename}`);
}
display(): void {
console.log(`Displaying ${this.filename}`);
}
}

class ProxyImage implements Image {
private realImage: RealImage;
constructor(private filename: string) {}
display(): void {
if (!this.realImage) {
this.realImage = new RealImage(this.filename);
}
this.realImage.display();
}
}

const image = new ProxyImage("test.jpg");
image.display(); // Loading test.jpg, Displaying test.jpg

10. Dependency Injection Pattern

Inverts control by injecting dependencies into a class rather than creating them internally.

Use Case

Service Layer:

  • Inject services (e.g., logging, database) into classes that need them.
  • Example: A UserService that depends on a Logger and Database service.

Testing:

  • Replace real dependencies with mock objects during testing.
  • Example: Injecting a MockLogger instead of a real Logger in unit tests.

Modular Applications:

  • Build modular applications where dependencies can be easily swapped or updated.
  • Example: A PaymentProcessor that can use different payment gateways (e.g., Stripe, PayPal) via dependency injection.

Implementation

class Logger {
log(message: string): void {
console.log(message);
}
}

class UserService {
constructor(private logger: Logger) {}
createUser(username: string): void {
this.logger.log(`User created: ${username}`);
}
}

const logger = new Logger();
const userService = new UserService(logger);
userService.createUser("John");

Design patterns are essential tools for writing clean, maintainable, and scalable TypeScript code. By understanding and applying these patterns, you can solve common problems effectively and improve the quality of your applications.

Start incorporating these patterns into your projects today!

--

--

Lakin Mohapatra
Lakin Mohapatra

Written by Lakin Mohapatra

Software Engineer | Hungry coder | Proud Indian | Cyber Security Researcher | Blogger | Architect (web2 + web 3)

Responses (1)