Common Design Patterns in TypeScript

TypeScript, a superset of JavaScript, provides robust typing, object-oriented programming (OOP) features, and other advanced language capabilities. These features make TypeScript an excellent choice for implementing design patterns, which are reusable solutions to common problems in software design. This article will explore some common design patterns in TypeScript, including Singleton, Factory, Observer, and Decorator patterns, with code examples and explanations.

Common Design Patterns in TypeScript

1. Singleton Pattern

The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. This is useful for managing shared resources or configurations.

Code Example

class Singleton {
   private static instance: Singleton;

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

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

   public showMessage(): void {
 	console.log("Hello from Singleton!");
   }
 }

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

 singleton1.showMessage();
 console.log(singleton1 === singleton2); // true
  •  Singleton class has a private static instance variable and a private constructor to prevent direct instantiation.
  • getInstance method checks if the instance already exists; if not, it creates one. This ensures only one instance is created.
  • The showMessage method demonstrates a possible method on the Singleton class.
  • When getInstance is called multiple times, the same instance is returned, confirming the Singleton behavior.

Usage Situations

The Singleton design pattern ensures that a class has only one instance and provides a global point of access to that instance. This pattern is useful in various scenarios, such as configuration management, logging, and resource management. Here are detailed explanations of these usages:

a. Configuration Management

Explanation:

In an application, there are often configuration settings that need to be accessed throughout the entire codebase. Examples include database connection strings, API keys, and other environment-specific settings. If multiple instances of the configuration object were allowed, it could lead to inconsistencies and harder maintenance.

Implementation:

Using the Singleton pattern for configuration management ensures that the configuration is loaded only once and shared across the application.

Example:

class ConfigurationManager {
   private static instance: ConfigurationManager;
   private config: { [key: string]: string };

   private constructor() {
 	// Load configuration settings (e.g., from a file or environment variables)
 	this.config = {
   	databaseUrl: process.env.DATABASE_URL || '',
   	apiKey: process.env.API_KEY || '',
   	// other settings
 	};
   }

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

   public get(key: string): string {
 	return this.config[key];
   }
 }

 // Usage
 const config = ConfigurationManager.getInstance();
 console.log(config.get('databaseUrl'));

b. Logging

Explanation:

Logging is a cross-cutting concern that should be consistent and thread-safe. Having multiple logger instances can lead to issues like file contention, inconsistent log formats, and difficulty in aggregating logs.

Implementation:

Using a Singleton logger ensures that all parts of the application use the same logging instance, making it easier to manage and maintain.

Example:

class Logger {
   private static instance: Logger;

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

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

   public log(message: string): void {
 	console.log(`[LOG] ${message}`);
   }
 }

 // Usage
 const logger = Logger.getInstance();
 logger.log('This is a log message.');

c. Resource Management

Explanation:

Resources such as thread pools, database connections, and cache instances are expensive to create and manage. Allowing multiple instances of these resources can lead to inefficiencies and resource exhaustion.

Implementation:

Using the Singleton pattern for resource management ensures that these resources are created once and reused, thus optimizing resource usage and performance.

Example:

class DatabaseConnection {
   private static instance: DatabaseConnection;

   private constructor() {
 	// Initialize the database connection
   }

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

   public query(sql: string): void {
 	// Execute the SQL query
   }
 }

 // Usage
 const dbConnection1 = DatabaseConnection.getInstance();
 dbConnection.query('SELECT * FROM users');
 const dbConnection2 = DatabaseConnection.getInstance();
 dbConnection.query('SELECT * FROM books');

 //Both of dbConnection1 and dbConnection2 will use the same connection.

2. Factory Pattern

The Factory pattern provides an interface for creating objects without specifying the exact class of object that will be created. It is useful for creating objects that require a lot of setup or that need to be configured in different ways.

Code Example

interface Product {
   operation(): void;
 }

 class ConcreteProductA implements Product {
   public operation(): void {
 	console.log("ConcreteProductA operation");
   }
 }

 class ConcreteProductB implements Product {
   public operation(): void {
 	console.log("ConcreteProductB operation");
   }
 }

 class Factory {
   public static createProduct(type: string): Product {
 	if (type === "A") {
   	return new ConcreteProductA();
 	} else if (type === "B") {
   	return new ConcreteProductB();
 	} else {
   	throw new Error("Invalid product type");
 	}
   }
 }

 // Usage
 const productA = Factory.createProduct("A");
 const productB = Factory.createProduct("B");

 productA.operation();
 productB.operation();

  • Product interface defines a common operation method for all products.
  • ConcreteProductA and ConcreteProductB implement the Product interface.
  • Factory class has a static method createProduct that takes a type string and returns an instance of the corresponding product class.
  • The factory method encapsulates the object creation logic, allowing for easier management and extension.

Usage Situations

a. Complex Object Creation

Explanation:

In some applications, creating objects can involve complex logic, configuration, or multiple steps. Managing this complexity within the main code can lead to clutter and difficult-to-maintain code. The Factory pattern encapsulates this complexity within a dedicated method or class.

Implementation:

Using the Factory pattern allows for centralizing and managing the object creation process, making the main code cleaner and more maintainable.

Example:

interface Computer {
   cpu: string;
   ram: string;
   storage: string;
 }

 class GamingComputer implements Computer {
   cpu = 'Intel i9';
   ram = '32GB';
   storage = '1TB SSD';
 }

 class OfficeComputer implements Computer {
   cpu = 'Intel i5';
   ram = '16GB';
   storage = '512GB SSD';
 }

 class ComputerFactory {
   static createComputer(type: string): Computer {
 	if (type === 'gaming') {
   	return new GamingComputer();
 	} else if (type === 'office') {
   	return new OfficeComputer();
 	} else {
   	throw new Error('Unknown computer type');
 	}
   }
 }

 // Usage
 const gamingPC = ComputerFactory.createComputer('gaming');
 const officePC = ComputerFactory.createComputer('office');

b. Object Pooling

Explanation:

In a typical web application, database connections are expensive to create because they require significant resources and time. To optimize performance, we can use an object pool to manage database connections. This pool will keep a set of initialized connections ready for use rather than creating and destroying connections on demand. This can significantly improve performance, especially under high load.

Implementation:

Using the Factory pattern, we can manage the creation and reuse of database connections by encapsulating the logic within a pool.

Example:

import { createConnection, Connection } from 'typeorm';

 class ConnectionPool {
   private static instance: ConnectionPool;
   private available: Connection[] = [];
   private inUse: Set<Connection> = new Set();

   private constructor() {}

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

   public async acquire(): Promise<Connection> {
 	let connection: Connection;
 	if (this.available.length > 0) {
   	connection = this.available.pop()!;
 	} else {
   	connection = await createConnection({
     	type: 'postgres',
     	host: 'localhost',
     	port: 5432,
     	username: 'test',
     	password: 'test',
         database: 'test',
   	});
 	}
 	this.inUse.add(connection);
 	return connection;
   }

   public release(connection: Connection): void {
 	if (this.inUse.delete(connection)) {
   	this.available.push(connection);
 	}
   }
 }

 // Usage
 (async () => {
   const pool = ConnectionPool.getInstance();
  
   // Acquiring a connection from the pool
   const connection1 = await pool.acquire();
  
   // Perform database operations...
  
   // Release the connection back to the pool
   pool.release(connection1);
 })();

c. Decoupling

Explanation:

Decoupling refers to the separation of components or systems so that they are not dependent on each other. In software design, this allows for more flexible and maintainable code. The Factory pattern can help decouple client code from the specific classes it instantiates, promoting loose coupling.

Implementation:

By using a factory to create instances, the client code does not need to know about the concrete implementations, only the interface or abstract class.

Example:

interface Button {
   render(): void;
 }

 class WindowsButton implements Button {
   render() {
 	console.log('Render Windows Button');
   }
 }

 class MacButton implements Button {
   render() {
 	console.log('Render Mac Button');
   }
 }

 class ButtonFactory {
   static createButton(os: string): Button {
 	if (os === 'windows') {
   	return new WindowsButton();
 	} else if (os === 'mac') {
   	return new MacButton();
 	} else {
   	throw new Error('Unknown OS');
 	}
   }
 }

 // Usage
 const os = 'windows';
 const button = ButtonFactory.createButton(os);
 button.render(); // Render Windows Button

3. 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. This is useful for implementing event handling systems.

Code Example

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

 class ConcreteObserver implements Observer {
   private name: string;

   constructor(name: string) {
 	this.name = name;
   }

   public 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 removeObserver(observer: Observer): void {
 	this.observers = this.observers.filter(obs => obs !== observer);
   }

   public notifyObservers(message: string): void {
 	this.observers.forEach(observer => observer.update(message));
   }
 }

 // Usage
 const observer1 = new ConcreteObserver("Observer1");
 const observer2 = new ConcreteObserver("Observer2");

 const subject = new Subject();
 subject.addObserver(observer1);
 subject.addObserver(observer2);

 subject.notifyObservers("Hello Observers!");
  • Observer interface defines an update method to be implemented by concrete observers.
  • ConcreteObserver implements the Observer interface and logs the received message.
  • Subject class maintains a list of observers and provides methods to add, remove, and notify them.
  • When notifyObservers (notifySubscripbers as below structure) is called, each observer's update method is triggered, demonstrating the observer pattern in action.

Usage Situations

a. Event Handling

Explanation:

In GUI components or other interactive systems, event handling is crucial for responding to user actions such as clicks, keystrokes, or other inputs. The Observer pattern can be used to implement event listeners that respond to these events and trigger appropriate actions.

Implementation:

Using the Observer pattern allows for a flexible and decoupled way of handling events, where event listeners (observers) can be added or removed dynamically.

Example:

class EventManager {
   private listeners: { [key: string]: Function[] } = {};

   public subscribe(event: string, listener: Function): void {
 	if (!this.listeners[event]) {
   	this.listeners[event] = [];
 	}
 	this.listeners[event].push(listener);
   }

   public unsubscribe(event: string, listener: Function): void {
 	if (!this.listeners[event]) return;
 	this.listeners[event] = this.listeners[event].filter(l => l !== listener);
   }

   public notify(event: string, data: any): void {
     if (!this.listeners[event]) return;
 	this.listeners[event].forEach(listener => listener(data));
   }
 }

 // Usage
 const eventManager = new EventManager();

 const onClick = (data: any) => console.log(`Button clicked with data: ${data}`);
 eventManager.subscribe('click', onClick);

 eventManager.notify('click', { x: 100, y: 200 });
 eventManager.unsubscribe('click', onClick);

Benefits:

●     Decoupling: Event listeners are decoupled from the event source.

●     Flexibility: Listeners can be dynamically added or removed.

b. Notification Systems

Explanation:

Notification systems send alerts or updates to multiple recipients when specific events occur. The Observer pattern ensures that all interested parties are notified whenever the event happens.

Implementation:

Using the Observer pattern for notifications allows for easy management of subscribers and ensures that all subscribers receive updates consistently.

Example:

class NotificationService {
   private observers: Function[] = [];

   public subscribe(observer: Function): void {
 	this.observers.push(observer);
   }

   public unsubscribe(observer: Function): void {
 	this.observers = this.observers.filter(obs => obs !== observer);
   }

   public notify(message: string): void {
 	this.observers.forEach(observer => observer(message));
   }
 }

 // Usage
 const notificationService = new NotificationService();

 const emailNotifier = (message: string) => console.log(`Email notification: ${message}`);
 const smsNotifier = (message: string) => console.log(`SMS notification: ${message}`);

 notificationService.subscribe(emailNotifier);
 notificationService.subscribe(smsNotifier);

 notificationService.notify('You have a new message!'); // Both email and sms have the message
 notificationService.unsubscribe(smsNotifier);
 notificationService.notify('Another message!'); // Just email notification has the message.

Benefits:

●     Scalability: Easily add new notification types without changing the core logic.

●     Consistency: Ensures all observers receive the same message.

c. Data Binding

Explanation:

In React, the Context API can be combined with the Observer pattern to ensure that changes in a data model are automatically reflected in the user interface.

Implementation:

We'll create a simple example with a context that holds some data and allows components to subscribe to changes in that data.

Step-by-Step Example

1. Create a DataModel Context

import React, { createContext, useContext, useState, useEffect } from 'react';

 // Create the context
 const DataModelContext = createContext(null);

 export const useDataModel = () => useContext(DataModelContext);

 export const DataModelProvider = ({ children }) => {
   const [data, setData] = useState({ name: '', age: 0 });

   const value = {
 	data,
 	setData,
   };

   return (
 	<DataModelContext.Provider value={value}>
   	{children}
 	</DataModelContext.Provider>
   );
 };

2. Create a Component to Display Data

import React from 'react';
 import { useDataModel } from './DataModelContext';

 const DisplayComponent = () => {
   const { data } = useDataModel();

   return <div>Data updated: {JSON.stringify(data)}</div>;
 };

 export default DisplayComponent;

3. Create a Component to Update Data

import React, { useState } from 'react';
 import { useDataModel } from './DataModelContext';

 const UpdateComponent = () => {
   const { setData } = useDataModel();
   const [name, setName] = useState('');
   const [age, setAge] = useState('');

   const updateData = () => {
 	setData({ name, age: parseInt(age) });
   };

   return (
 	<div>
   	<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
   	<input type="number" placeholder="Age" value={age} onChange={(e) => setAge(e.target.value)} />
   	<button onClick={updateData}>Update Data</button>
 	</div>
   );
 };

 export default UpdateComponent;

4. Integrate Everything in the App Component


import React from 'react';
 import { DataModelProvider } from './DataModelContext';
 import DisplayComponent from './DisplayComponent';
 import UpdateComponent from './UpdateComponent';

 const App = () => {
   return (
 	<DataModelProvider>
   	<div>
     	<h1>Simple React Context with Observer Pattern</h1>
     	<UpdateComponent />
     	<DisplayComponent />
   	</div>
 	</DataModelProvider>
   );
 };

 export default App;

Benefits:

●     Automatic Updates: The DisplayComponent automatically updates when the UpdateComponent changes the data.

●     Maintainability: The separation of concerns makes it easy to manage and update.

4. Decorator Pattern

The Decorator pattern allows behavior to be added to individual objects, either statically or dynamically, without affecting the behavior of other objects from the same class. It is useful for adhering to the open/closed principle.

Code Example

interface Component {
   operation(): string;
 }

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

 class Decorator implements Component {
   protected component: Component;

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

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

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

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

 // Usage
 const simple = new ConcreteComponent();
 console.log(simple.operation());

 const decoratorA = new ConcreteDecoratorA(simple);
 console.log(decoratorA.operation());

 const decoratorB = new ConcreteDecoratorB(decoratorA);
 console.log(decoratorB.operation());
  • Component interface defines an operation method.
  • ConcreteComponent implements the Component interface.
  • Decorator class implements the Component interface and wraps a Component instance.
  • ConcreteDecoratorA and ConcreteDecoratorB extend the Decorator class, adding extra behavior to the operation method.
  • By wrapping ConcreteComponent with decorators, additional behaviors are added dynamically.

Usage Situations

a. Adding Functionality

Explanation:

The Decorator pattern allows adding new functionality to an object without modifying its underlying class. This can be especially useful when you want to add features to objects that are instantiated from third-party libraries or frameworks.

Example:

Consider a real-world example of an online shopping system where you need to add discount functionality to different product objects:

Example:

interface Product {
   getPrice(): number;
 }

 class BasicProduct implements Product {
   private price: number;

   constructor(price: number) {
 	this.price = price;
   }

   getPrice(): number {
 	return this.price;
   }
 }

 class DiscountDecorator implements Product {
   protected product: Product;
   protected discount: number;

   constructor(product: Product, discount: number) {
 	this.product = product;
 	this.discount = discount;
   }

   getPrice(): number {
 	return this.product.getPrice() * (1 - this.discount);
   }
 }

 // Usage
 const basicProduct = new BasicProduct(100);
 const discountedProduct = new DiscountDecorator(basicProduct, 0.1); // 10% discount

 console.log(discountedProduct.getPrice()); // Output: 90

Benefits:

●     Non-intrusive: Add functionality without modifying the existing class.

●     Reusability: Apply the same decorator to different product objects.

2. Flexible Composition

Explanation:

The Decorator pattern allows combining different behaviors in various ways without creating a complex class hierarchy. This provides flexibility in composing objects with different sets of behaviors.

Example:

Consider a text editor application where you can add various text formatting features like bold, italic, and underline:

Example:

interface TextComponent {
   render(): string;
 }

 class PlainText implements TextComponent {
   private text: string;

   constructor(text: string) {
 	this.text = text;
   }

   render(): string {
 	return this.text;
   }
 }

 class BoldDecorator implements TextComponent {
   protected component: TextComponent;

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

   render(): string {
 	return `<b>${this.component.render()}</b>`;
   }
 }

 class ItalicDecorator implements TextComponent {
   protected component: TextComponent;

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

   render(): string {
 	return `<i>${this.component.render()}</i>`;
   }
 }

 // Usage
 const plainText = new PlainText("Hello, world!");
 const boldText = new BoldDecorator(plainText);
 const boldItalicText = new ItalicDecorator(boldText);

 console.log(boldItalicText.render()); // Output: <i><b>Hello, world!</b></i>

Benefits:

●     Composable: Combine multiple behaviors in various configurations.

●     Avoids Inheritance: Use composition over inheritance, leading to more flexible and maintainable code.

3. Enhancing Objects

Explanation:

The Decorator pattern allows you to extend or enhance the behavior of objects in a flexible and reusable manner. This is useful when you need to dynamically change an object's behavior at runtime.

Example:

Consider an example of enhancing a logging system in an application:

interface Logger {
   log(message: string): void;
 }

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

 class TimestampLoggerDecorator implements Logger {
   protected logger: Logger;

   constructor(logger: Logger) {
 	this.logger = logger;
   }

   log(message: string): void {
 	const timestamp = new Date().toISOString();
 	this.logger.log(`[${timestamp}] ${message}`);
   }
 }

 class LevelLoggerDecorator implements Logger {
   protected logger: Logger;
   protected level: string;

   constructor(logger: Logger, level: string) {
 	this.logger = logger;
 	this.level = level;
   }

   log(message: string): void {
 	this.logger.log(`[${this.level}] ${message}`);
   }
 }

 // Usage
 const basicLogger = new BasicLogger();
 const timestampLogger = new TimestampLoggerDecorator(basicLogger);
 const levelTimestampLogger = new LevelLoggerDecorator(timestampLogger, "INFO");

 levelTimestampLogger.log("This is a log message.");
 // Output: [INFO] [2023-04-11T10:20:30.456Z] This is a log message.

Benefits:

  • Dynamic Behavior: Dynamically add or modify the behavior of objects.
  • Extensible: Easily extend the functionality of objects without altering existing code.

Conclusion

Design patterns provide tested, proven development paradigms, making your code more flexible, reusable, and maintainable. TypeScript's robust features and support for OOP principles make it an excellent language for implementing these patterns. By understanding and applying patterns like Singleton, Factory, Observer, and Decorator, you can create more robust and maintainable applications.

The examples provided in this article demonstrate how these patterns can be applied in real-world scenarios. From adding dynamic functionality to products and managing complex object creation to handling real-time notifications and enhancing logging systems, these patterns offer versatile solutions that can be tailored to various development needs. As the author of this article, I often encounter these patterns in both personal projects and professional software development. They provide a solid foundation for creating scalable and maintainable code. The frequency of their use depends on the specific requirements of the project. For instance, Singleton and Factory patterns are almost ubiquitous in backend systems dealing with resource management and object creation. Observer patterns shine in applications requiring real-time updates, such as chat applications or live data dashboards. Decorator patterns are invaluable in enhancing and extending functionalities in a modular and reusable way.

Understanding and utilizing these design patterns can significantly improve the quality of your software. They not only solve common problems efficiently but also promote best practices in object-oriented design, leading to cleaner, more maintainable, and more flexible codebases. By incorporating these patterns into your development workflow, you can tackle complex design challenges with confidence and elegance.

For more details and illustrations on these patterns, please visit Refactoring.Guru.