TypeScript Design Mastery: Patterns Unlocked
Sehban Alam
Software Engineer (Creating Scalable, Secure, Cloud-Powered Applications that Redefine B2B/B2C User Experiences) | Angular | Firebase | Cloudflare | Material Design | Serverless | MySQL | GCP | AWS
Design patterns are tried and tested solutions to common problems in software design. In TypeScript, design patterns help create maintainable, reusable, and scalable applications by leveraging modern features like types, interfaces, and classes. In this post, we will cover some of the most commonly used design patterns in TypeScript: Singleton, Factory, Observer, Strategy, and Decorator. Each pattern will be explained with code examples, advantages, disadvantages, and real-world use cases.
1. Singleton Pattern
The Singleton pattern restricts the instantiation of a class to a single instance, providing a global point of access.
Code Example
class Singleton {
private static instance: Singleton;
private constructor() {}
static getInstance(): Singleton {
if (!Singleton.instance) {
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
public log(): void {
console.log("Logging from the Singleton instance");
}
}
// Usage
const singleton1 = Singleton.getInstance();
const singleton2 = Singleton.getInstance();
console.log(singleton1 === singleton2); // true
Real-World Example: Global Configuration
Consider an application where you need a global configuration object accessible throughout the app without creating multiple instances.
class Config {
private static instance: Config;
public apiUrl: string;
private constructor() {
this.apiUrl = "https://api.example.com";
}
static getInstance(): Config {
if (!Config.instance) {
Config.instance = new Config();
}
return Config.instance;
}
}
// Usage
const config1 = Config.getInstance();
const config2 = Config.getInstance();
console.log(config1.apiUrl); // https://api.example.com
console.log(config1 === config2); // true
2. Factory Pattern
The Factory pattern provides an interface for creating objects but allows subclasses to alter the type of objects that will be created.
Code Example
interface Animal {
speak(): void;
}
class Dog implements Animal {
speak(): void {
console.log("Woof!");
}
}
class Cat implements Animal {
speak(): void {
console.log("Meow!");
}
}
class AnimalFactory {
static createAnimal(type: string): Animal {
switch(type) {
case 'dog': return new Dog();
case 'cat': return new Cat();
default: throw new Error("Invalid animal type");
}
}
}
// Usage
const dog = AnimalFactory.createAnimal("dog");
dog.speak(); // Woof!
Real-World Example: User Role Factory
In an authentication system, you may need to create different user roles (Admin, Guest, Member), each with different permissions.
interface User {
getPermissions(): string[];
}
class Admin implements User {
getPermissions(): string[] {
return ["create", "read", "update", "delete"];
}
}
class Guest implements User {
getPermissions(): string[] {
return ["read"];
}
}
class Member implements User {
getPermissions(): string[] {
return ["read", "update"];
}
}
class UserFactory {
static createUser(role: string): User {
switch (role) {
case 'admin': return new Admin();
case 'guest': return new Guest();
case 'member': return new Member();
default: throw new Error("Invalid role");
}
}
}
// Usage
const admin = UserFactory.createUser("admin");
console.log(admin.getPermissions()); // ["create", "read", "update", "delete"]
3. Observer Pattern
The Observer pattern defines a subscription mechanism to notify multiple objects about any state changes.
Code Example
class Subject {
private observers: Observer[] = [];
addObserver(observer: Observer): void {
this.observers.push(observer);
}
removeObserver(observer: Observer): void {
this.observers = this.observers.filter(obs => obs !== observer);
}
notifyObservers(): void {
for (const observer of this.observers) {
observer.update();
}
}
}
interface Observer {
update(): void;
}
class ConcreteObserver implements Observer {
update(): void {
console.log("Observer notified");
}
}
// Usage
const subject = new Subject();
const observer1 = new ConcreteObserver();
const observer2 = new ConcreteObserver();
subject.addObserver(observer1);
subject.addObserver(observer2);
subject.notifyObservers(); // Observer notified (twice)
Real-World Example: Stock Market App
In a stock market app, different components may need to be notified of stock price changes.
class StockMarket {
private observers: StockObserver[] = [];
addObserver(observer: StockObserver): void {
this.observers.push(observer);
}
removeObserver(observer: StockObserver): void {
this.observers = this.observers.filter(obs => obs !== observer);
}
updatePrice(newPrice: number): void {
console.log(`Stock price updated to ${newPrice}`);
this.notifyObservers(newPrice);
}
private notifyObservers(newPrice: number): void {
for (const observer of this.observers) {
observer.update(newPrice);
}
}
}
interface StockObserver {
update(price: number): void;
}
class Investor implements StockObserver {
update(price: number): void {
console.log(`Investor notified of new price: ${price}`);
}
}
// Usage
const market = new StockMarket();
const investor1 = new Investor();
const investor2 = new Investor();
market.addObserver(investor1);
market.addObserver(investor2);
market.updatePrice(100); // Investor notified of new price: 100 (twice)
4. Strategy Pattern
The Strategy pattern allows selecting an algorithm at runtime from a family of algorithms.
Code Example
领英推荐
interface PaymentStrategy {
pay(amount: number): void;
}
class CreditCardPayment implements PaymentStrategy {
pay(amount: number): void {
console.log(`Paid ${amount} using credit card.`);
}
}
class PayPalPayment implements PaymentStrategy {
pay(amount: number): void {
console.log(`Paid ${amount} using PayPal.`);
}
}
class PaymentProcessor {
constructor(private strategy: PaymentStrategy) {}
processPayment(amount: number): void {
this.strategy.pay(amount);
}
}
// Usage
const creditCardPayment = new CreditCardPayment();
const paypalPayment = new PayPalPayment();
const paymentProcessor = new PaymentProcessor(creditCardPayment);
paymentProcessor.processPayment(100); // Paid 100 using credit card.
paymentProcessor = new PaymentProcessor(paypalPayment);
paymentProcessor.processPayment(200); // Paid 200 using PayPal.
Real-World Example: File Compression
In an application, you can select different file compression algorithms at runtime, like ZIP or RAR.
interface CompressionStrategy {
compress(files: string[]): void;
}
class ZipCompression implements CompressionStrategy {
compress(files: string[]): void {
console.log(`Compressing ${files.length} files using ZIP`);
}
}
class RarCompression implements CompressionStrategy {
compress(files: string[]): void {
console.log(`Compressing ${files.length} files using RAR`);
}
}
class CompressionContext {
constructor(private strategy: CompressionStrategy) {}
compressFiles(files: string[]): void {
this.strategy.compress(files);
}
}
// Usage
const zipCompression = new ZipCompression();
const rarCompression = new RarCompression();
const files = ["file1.txt", "file2.txt"];
const context = new CompressionContext(zipCompression);
context.compressFiles(files); // Compressing 2 files using ZIP
context = new CompressionContext(rarCompression);
context.compressFiles(files); // Compressing 2 files using RAR
5. Decorator Pattern
The Decorator pattern allows adding new functionality to an object dynamically without altering its structure.
Code Example
interface Coffee {
cost(): number;
description(): string;
}
class BasicCoffee implements Coffee {
cost(): number {
return 5;
}
description(): string {
return "Basic Coffee";
}
}
class MilkDecorator implements Coffee {
constructor(private coffee: Coffee) {}
cost(): number {
return this.coffee.cost() + 2;
}
description(): string {
return `${this.coffee.description()}, with milk`;
}
}
class SugarDecorator implements Coffee {
constructor(private coffee: Coffee) {}
cost(): number {
return this.coffee.cost() + 1;
}
description(): string {
return `${this.coffee.description()}, with sugar`;
}
}
// Usage
let myCoffee: Coffee = new BasicCoffee();
myCoffee = new MilkDecorator(myCoffee);
myCoffee = new SugarDecorator(myCoffee);
console.log(myCoffee.description()); // Basic Coffee, with milk, with sugar
console.log(myCoffee.cost()); // 8
Real-World Example: API Request Logging
In an API service, you may need to add additional functionality, such as logging, without modifying the core request logic. typescript
interface ApiService {
fetchData(): void;
}
class BasicApiService implements ApiService {
fetchData(): void {
console.log("Fetching data from API");
}
}
class LoggingDecorator implements ApiService {
constructor(private service: ApiService) {}
fetchData(): void {
console.log("Logging API request");
this.service.fetchData();
}
}
class ErrorHandlingDecorator implements ApiService {
constructor(private service: ApiService) {}
fetchData(): void {
try {
this.service.fetchData();
} catch (error) {
console.error("Error occurred during API request");
}
}
}
// Usage
let apiService: ApiService = new BasicApiService();
apiService = new LoggingDecorator(apiService);
apiService = new ErrorHandlingDecorator(apiService);
apiService.fetchData();
// Logging API request
// Fetching data from API
In this example, we are enhancing the functionality of a basic API service by adding logging and error-handling features using decorators, without modifying the core service.
How to Choose a Pattern Effectively
Choosing the right design pattern depends on several factors:
Identify the Problem:
Simplicity vs. Flexibility:
Code Maintainability:
Performance Considerations:
Team and Project Size:
Conclusion
Design patterns are powerful tools for creating scalable, maintainable, and efficient TypeScript applications. While each pattern has its advantages and disadvantages, understanding when and how to apply them is crucial for solving specific design challenges. Whether it’s handling global state with the Singleton pattern or allowing dynamic behavior changes with the Strategy or Decorator patterns, selecting the right design pattern can significantly improve the quality of your code.
However, avoid over-engineering — use patterns only when they provide clear benefits. Start by identifying your problem, weighing the pros and cons of each pattern, and considering the scalability and flexibility of your application.