Design Patterns in TypeScript

TypeScript Intermediate — Design Patterns in TypeScript
TypeScript Intermediate
Course 2 · Chapter 9 · Design Patterns in TypeScript

🏗️ Design Patterns in TypeScript

Design patterns are proven solutions to common problems. This final chapter brings together everything you've learned—generics, decorators, async, error handling, type utilities—to build real-world patterns with TypeScript's type system. Master these patterns and you'll write professional-grade code.

👤 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.

→ Solution

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.

→ Solution

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.

→ Solution

💡 Choose Patterns Carefully

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.