Design Patterns in TypeScript
🏗️ Design Patterns in TypeScript
👤 Singleton: One Instance
Ensure only one instance of a class exists globally:
class Logger { private static instance: Logger | null = null; private logs: string[] = []; private constructor() {} static getInstance(): Logger { if (Logger.instance === null) { Logger.instance = new Logger(); } return Logger.instance; } log(message: string) { this.logs.push(message); console.log(`[${new Date().toISOString()}] ${message}`); } getLogs(): string[] { return [...this.logs]; } } // Usage: always the same instance const logger1 = Logger.getInstance(); const logger2 = Logger.getInstance(); logger1 === logger2; // true logger1.log("Hello"); console.log(logger2.getLogs()); // ["Hello"]
🏭 Factory: Flexible Creation
Create objects without exposing creation logic. Useful for supporting multiple implementations:
interface Database { query(sql: string): Promise<any[]>; close(): Promise<void>; } class PostgreSQL implements Database { async query(sql: string) { console.log(`PostgreSQL: ${sql}`); return []; } async close() {} } class MySQL implements Database { async query(sql: string) { console.log(`MySQL: ${sql}`); return []; } async close() {} } // Factory: create the right DB without exposing the type function createDatabase(type: "postgres" | "mysql"): Database { if (type === "postgres") { return new PostgreSQL(); } else { return new MySQL(); } } // Usage: caller doesn't know which DB they have const db: Database = createDatabase("postgres"); await db.query("SELECT * FROM users");
👁️ Observer: Reactive Updates
Notify multiple objects when state changes (event-driven architecture):
interface Observer { update(data: unknown): void; } class Subject { private observers: Observer[] = []; attach(observer: Observer) { this.observers.push(observer); } detach(observer: Observer) { this.observers = this.observers.filter(o => o !== observer); } notify(data: unknown) { this.observers.forEach(o => o.update(data)); } } // Concrete observers class Logger implements Observer { update(data: unknown) { console.log(`[Log] ${JSON.stringify(data)}`); } } class EmailNotifier implements Observer { update(data: unknown) { console.log(`[Email] Sending notification for ${data}`); } } // Usage const subject = new Subject(); subject.attach(new Logger()); subject.attach(new EmailNotifier()); subject.notify({ event: "user.created", userId: 1 });
⚙️ Strategy: Swappable Algorithms
Encapsulate algorithms so they're interchangeable at runtime:
interface SortStrategy { sort(arr: number[]): number[]; } class BubbleSort implements SortStrategy { sort(arr: number[]): number[] { console.log("Sorting with Bubble Sort"); // Bubble sort implementation return [...arr].sort(); } } class QuickSort implements SortStrategy { sort(arr: number[]): number[] { console.log("Sorting with Quick Sort"); // Quick sort implementation return [...arr].sort(); } } class Sorter { constructor(private strategy: SortStrategy) {} execute(arr: number[]): number[] { return this.strategy.sort(arr); } } // Usage: swap strategies at runtime const sorter1 = new Sorter(new BubbleSort()); console.log(sorter1.execute([3, 1, 2])); // [1, 2, 3] const sorter2 = new Sorter(new QuickSort()); console.log(sorter2.execute([3, 1, 2])); // [1, 2, 3]
💉 Dependency Injection: Loose Coupling
Pass dependencies explicitly instead of hardcoding. Makes code testable and flexible:
// BAD: hardcoded dependency class UserService_Bad { private db = new PostgreSQL(); // Tightly coupled } // GOOD: inject dependency class UserService { constructor(private db: Database) {} async getUser(id: number) { return await this.db.query(`SELECT * FROM users WHERE id = ${id}`); } } // Usage: can swap DB implementation const service = new UserService(new PostgreSQL()); await service.getUser(1); // Testing: use mock class MockDB implements Database { async query() { return [{ id: 1, name: "Test User" }]; } async close() {} } const testService = new UserService(new MockDB()); // Now testable without real database!
💻 Coding Challenges
Challenge 1: Singleton Logger
Implement a Logger singleton with methods to log at different levels (info, warn, error). Ensure only one instance exists across the app.
Goal: Practice singleton pattern with application-level state.
Challenge 2: Factory with Type Safety
Create a factory that builds different notification strategies (Email, SMS, Push). Use generics to ensure type safety across implementations.
Goal: Combine factory pattern with generics.
Challenge 3: Observer with Type-Safe Events
Implement an event bus with typed events (discriminated union). Add/remove listeners for specific events. Emit with type safety.
Goal: Combine Observer pattern with type utilities.
Not every problem needs a design pattern. Start simple, refactor into patterns when complexity demands it. The best code is often the simplest code that solves the problem. Patterns are tools for specific situations, not dogma.
🎯 Course Complete!
You've mastered TypeScript Intermediate. You now understand advanced types, generics, decorators, async patterns, modules, OOP, type utilities, error handling, and design patterns. You're ready to build professional-grade TypeScript applications. Consider exploring Advanced TypeScript (Course 3) for even deeper mastery—or apply these skills to real projects, where the best learning happens.