TypeScript Design Patterns: A Comprehensive Guide
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 createsPrimaryButton
,SecondaryButton
, etc.
Payment Methods:
- Instantiate different payment processors (e.g., credit card, PayPal) based on user selection.
- Example: A
PaymentFactory
that createsCreditCardPayment
,PayPalPayment
, etc.
Game Characters:
- Create different types of game characters (e.g., warriors, mages) dynamically.
- Example: A
CharacterFactory
that createsWarrior
,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 aSortStrategy
to perform sorting.
Payment Methods:
- Allow users to choose between different payment methods (e.g., credit card, PayPal).
- Example: A
PaymentProcessor
that uses aPaymentStrategy
to process payments.
Compression Algorithms:
- Switch between different compression algorithms (e.g., ZIP, RAR) based on user preference.
- Example: A
Compressor
class that uses aCompressionStrategy
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 aButton
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 likeTurnOnLightCommand
,TurnOffTVCommand
.
Task Scheduling:
- Schedule and execute tasks at specific times or events.
- Example: A
Scheduler
that executesSendEmailCommand
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 likevalidateOrder
,processPayment
,shipOrder
.
Game Development:
- Define a common game loop with customizable steps (e.g., initialize, start, end).
- Example: A
Game
class with steps likeinitialize
,startPlay
,endPlay
.
Report Generation:
- Generate reports with a common structure but customizable content.
- Example: A
ReportGenerator
class with steps likefetchData
,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 whendisplay
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 aLogger
andDatabase
service.
Testing:
- Replace real dependencies with mock objects during testing.
- Example: Injecting a
MockLogger
instead of a realLogger
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!