Back to Blog
Design PatternsJavaScriptTypeScriptArchitecture

Design Patterns in JavaScript and TypeScript

Apply classic design patterns in modern JavaScript. Learn creational, structural, and behavioral patterns with practical examples.

B
Bootspring Team
Engineering
February 27, 2026
6 min read

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); // true

Factory#

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

Facade#

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 operations

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

Share this article

Help spread the word about Bootspring