Design patterns are reusable solutions to common problems. This guide covers essential patterns adapted for modern JavaScript and TypeScript.
Creational Patterns#
Singleton#
Ensure a class has only one instance:
1class Database {
2 private static instance: Database;
3 private connection: Connection;
4
5 private constructor() {
6 this.connection = createConnection();
7 }
8
9 static getInstance(): Database {
10 if (!Database.instance) {
11 Database.instance = new Database();
12 }
13 return Database.instance;
14 }
15
16 query(sql: string) {
17 return this.connection.execute(sql);
18 }
19}
20
21// Usage
22const db1 = Database.getInstance();
23const db2 = Database.getInstance();
24console.log(db1 === db2); // trueFactory#
Create objects without specifying exact class:
1interface Notification {
2 send(message: string): void;
3}
4
5class EmailNotification implements Notification {
6 send(message: string) {
7 console.log(`Email: ${message}`);
8 }
9}
10
11class SMSNotification implements Notification {
12 send(message: string) {
13 console.log(`SMS: ${message}`);
14 }
15}
16
17class PushNotification implements Notification {
18 send(message: string) {
19 console.log(`Push: ${message}`);
20 }
21}
22
23class NotificationFactory {
24 static create(type: 'email' | 'sms' | 'push'): Notification {
25 switch (type) {
26 case 'email':
27 return new EmailNotification();
28 case 'sms':
29 return new SMSNotification();
30 case 'push':
31 return new PushNotification();
32 default:
33 throw new Error(`Unknown notification type: ${type}`);
34 }
35 }
36}
37
38// Usage
39const notification = NotificationFactory.create('email');
40notification.send('Hello!');Builder#
Construct complex objects step by step:
1class QueryBuilder {
2 private query: string = '';
3 private params: any[] = [];
4
5 select(fields: string[]): this {
6 this.query += `SELECT ${fields.join(', ')} `;
7 return this;
8 }
9
10 from(table: string): this {
11 this.query += `FROM ${table} `;
12 return this;
13 }
14
15 where(condition: string, value: any): this {
16 this.query += `WHERE ${condition} `;
17 this.params.push(value);
18 return this;
19 }
20
21 orderBy(field: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
22 this.query += `ORDER BY ${field} ${direction} `;
23 return this;
24 }
25
26 limit(count: number): this {
27 this.query += `LIMIT ${count}`;
28 return this;
29 }
30
31 build(): { query: string; params: any[] } {
32 return { query: this.query.trim(), params: this.params };
33 }
34}
35
36// Usage
37const { query, params } = new QueryBuilder()
38 .select(['id', 'name', 'email'])
39 .from('users')
40 .where('age > ?', 18)
41 .orderBy('name')
42 .limit(10)
43 .build();Structural Patterns#
Adapter#
Make incompatible interfaces work together:
1// Old payment system
2class LegacyPaymentSystem {
3 processPayment(amount: number, account: string) {
4 console.log(`Legacy: Processing $${amount} for ${account}`);
5 }
6}
7
8// New interface we want to use
9interface PaymentProcessor {
10 pay(details: { amount: number; method: string; reference: string }): void;
11}
12
13// Adapter
14class LegacyPaymentAdapter implements PaymentProcessor {
15 constructor(private legacy: LegacyPaymentSystem) {}
16
17 pay(details: { amount: number; method: string; reference: string }) {
18 this.legacy.processPayment(details.amount, details.reference);
19 }
20}
21
22// Usage
23const legacySystem = new LegacyPaymentSystem();
24const adapter: PaymentProcessor = new LegacyPaymentAdapter(legacySystem);
25
26adapter.pay({ amount: 100, method: 'card', reference: 'ORDER-123' });Decorator#
Add behavior to objects dynamically:
1interface Coffee {
2 cost(): number;
3 description(): string;
4}
5
6class SimpleCoffee implements Coffee {
7 cost() { return 2; }
8 description() { return 'Coffee'; }
9}
10
11// Decorators
12abstract class CoffeeDecorator implements Coffee {
13 constructor(protected coffee: Coffee) {}
14 abstract cost(): number;
15 abstract description(): string;
16}
17
18class MilkDecorator extends CoffeeDecorator {
19 cost() { return this.coffee.cost() + 0.5; }
20 description() { return `${this.coffee.description()}, Milk`; }
21}
22
23class SugarDecorator extends CoffeeDecorator {
24 cost() { return this.coffee.cost() + 0.25; }
25 description() { return `${this.coffee.description()}, Sugar`; }
26}
27
28class WhippedCreamDecorator extends CoffeeDecorator {
29 cost() { return this.coffee.cost() + 0.75; }
30 description() { return `${this.coffee.description()}, Whipped Cream`; }
31}
32
33// Usage
34let coffee: Coffee = new SimpleCoffee();
35coffee = new MilkDecorator(coffee);
36coffee = new SugarDecorator(coffee);
37coffee = new WhippedCreamDecorator(coffee);
38
39console.log(coffee.description()); // Coffee, Milk, Sugar, Whipped Cream
40console.log(coffee.cost()); // 3.5Facade#
Provide simple interface to complex subsystems:
1// Complex subsystems
2class VideoDecoder {
3 decode(file: string) { console.log(`Decoding ${file}`); }
4}
5
6class AudioProcessor {
7 process(file: string) { console.log(`Processing audio for ${file}`); }
8}
9
10class VideoRenderer {
11 render(file: string) { console.log(`Rendering ${file}`); }
12}
13
14class SubtitleLoader {
15 load(file: string) { console.log(`Loading subtitles for ${file}`); }
16}
17
18// Facade
19class VideoPlayer {
20 private decoder = new VideoDecoder();
21 private audio = new AudioProcessor();
22 private renderer = new VideoRenderer();
23 private subtitles = new SubtitleLoader();
24
25 play(file: string) {
26 this.decoder.decode(file);
27 this.audio.process(file);
28 this.subtitles.load(file);
29 this.renderer.render(file);
30 console.log('Playing video...');
31 }
32}
33
34// Usage
35const player = new VideoPlayer();
36player.play('movie.mp4'); // Simple interface to complex operationsBehavioral Patterns#
Observer#
Define subscription mechanism for events:
1interface Observer<T> {
2 update(data: T): void;
3}
4
5class Subject<T> {
6 private observers: Set<Observer<T>> = new Set();
7
8 subscribe(observer: Observer<T>) {
9 this.observers.add(observer);
10 }
11
12 unsubscribe(observer: Observer<T>) {
13 this.observers.delete(observer);
14 }
15
16 notify(data: T) {
17 this.observers.forEach(observer => observer.update(data));
18 }
19}
20
21// Usage
22interface StockPrice {
23 symbol: string;
24 price: number;
25}
26
27const stockTicker = new Subject<StockPrice>();
28
29const logger: Observer<StockPrice> = {
30 update(data) {
31 console.log(`${data.symbol}: $${data.price}`);
32 },
33};
34
35const alerter: Observer<StockPrice> = {
36 update(data) {
37 if (data.price > 100) {
38 console.log(`ALERT: ${data.symbol} exceeded $100!`);
39 }
40 },
41};
42
43stockTicker.subscribe(logger);
44stockTicker.subscribe(alerter);
45
46stockTicker.notify({ symbol: 'AAPL', price: 150 });Strategy#
Define family of interchangeable algorithms:
1interface SortStrategy<T> {
2 sort(data: T[]): T[];
3}
4
5class QuickSort<T> implements SortStrategy<T> {
6 sort(data: T[]): T[] {
7 // Quick sort implementation
8 return [...data].sort();
9 }
10}
11
12class MergeSort<T> implements SortStrategy<T> {
13 sort(data: T[]): T[] {
14 // Merge sort implementation
15 return [...data].sort();
16 }
17}
18
19class Sorter<T> {
20 constructor(private strategy: SortStrategy<T>) {}
21
22 setStrategy(strategy: SortStrategy<T>) {
23 this.strategy = strategy;
24 }
25
26 sort(data: T[]): T[] {
27 return this.strategy.sort(data);
28 }
29}
30
31// Usage
32const sorter = new Sorter(new QuickSort<number>());
33console.log(sorter.sort([3, 1, 4, 1, 5]));
34
35sorter.setStrategy(new MergeSort<number>());
36console.log(sorter.sort([3, 1, 4, 1, 5]));Command#
Encapsulate request as an object:
1interface Command {
2 execute(): void;
3 undo(): void;
4}
5
6class AddTextCommand implements Command {
7 constructor(
8 private editor: TextEditor,
9 private text: string
10 ) {}
11
12 execute() {
13 this.editor.insert(this.text);
14 }
15
16 undo() {
17 this.editor.delete(this.text.length);
18 }
19}
20
21class TextEditor {
22 private content = '';
23
24 insert(text: string) {
25 this.content += text;
26 }
27
28 delete(count: number) {
29 this.content = this.content.slice(0, -count);
30 }
31
32 getContent() {
33 return this.content;
34 }
35}
36
37class CommandManager {
38 private history: Command[] = [];
39
40 execute(command: Command) {
41 command.execute();
42 this.history.push(command);
43 }
44
45 undo() {
46 const command = this.history.pop();
47 command?.undo();
48 }
49}
50
51// Usage
52const editor = new TextEditor();
53const manager = new CommandManager();
54
55manager.execute(new AddTextCommand(editor, 'Hello '));
56manager.execute(new AddTextCommand(editor, 'World'));
57console.log(editor.getContent()); // "Hello World"
58
59manager.undo();
60console.log(editor.getContent()); // "Hello "Conclusion#
Design patterns provide tested solutions to common problems. Don't overuse them—apply patterns when they genuinely simplify your code. The best pattern is often the simplest solution that works.