TypeScript Design Patterns
Design patterns are proven solutions to common software design problems. TypeScript's type system makes implementing these patterns safer and more expressive than in vanilla JavaScript. In this lesson, we'll explore classic design patterns—Singleton, Factory, Observer, and Strategy—and see how TypeScript's features enhance their implementation.
Singleton Pattern
The Singleton pattern ensures a class has only one instance and provides a global access point to it:
// Basic Singleton implementation
class DatabaseConnection {
private static instance: DatabaseConnection;
private connected: boolean = false;
// Private constructor prevents direct instantiation
private constructor(
private readonly connectionString: string
) {}
static getInstance(connectionString: string): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection(connectionString);
}
return DatabaseConnection.instance;
}
connect(): void {
if (!this.connected) {
console.log(\`Connecting to ${this.connectionString}\`);
this.connected = true;
}
}
query(sql: string): void {
if (!this.connected) {
throw new Error('Not connected to database');
}
console.log(\`Executing: ${sql}\`);
}
}
// Usage
const db1 = DatabaseConnection.getInstance('postgres://localhost');
const db2 = DatabaseConnection.getInstance('mysql://localhost');
console.log(db1 === db2); // true - same instance
// Generic Singleton pattern
class Singleton<T> {
private static instances = new Map<Function, unknown>();
protected constructor() {}
static getInstance<T extends Singleton<any>>(
this: new () => T
): T {
if (!Singleton.instances.has(this)) {
Singleton.instances.set(this, new this());
}
return Singleton.instances.get(this) as T;
}
}
// Using generic Singleton
class Logger extends Singleton<Logger> {
private logs: string[] = [];
log(message: string): void {
this.logs.push(\`[${new Date().toISOString()}] ${message}\`);
}
getLogs(): string[] {
return [...this.logs];
}
}
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();
console.log(logger1 === logger2); // true
// Singleton with lazy initialization and thread safety
class ConfigManager {
private static instance: ConfigManager | null = null;
private static isCreating = false;
private config: Map<string, unknown> = new Map();
private constructor() {}
static async getInstance(): Promise<ConfigManager> {
if (ConfigManager.instance) {
return ConfigManager.instance;
}
// Prevent race conditions
while (ConfigManager.isCreating) {
await new Promise(resolve => setTimeout(resolve, 10));
}
if (!ConfigManager.instance) {
ConfigManager.isCreating = true;
ConfigManager.instance = new ConfigManager();
await ConfigManager.instance.loadConfig();
ConfigManager.isCreating = false;
}
return ConfigManager.instance;
}
private async loadConfig(): Promise<void> {
// Simulate async config loading
const response = await fetch('/api/config');
const data = await response.json();
Object.entries(data).forEach(([key, value]) => {
this.config.set(key, value);
});
}
get<T>(key: string): T | undefined {
return this.config.get(key) as T | undefined;
}
}
Note: The Singleton pattern is useful for managing shared resources like database connections, configuration, or logging. However, use it sparingly as it introduces global state.
Factory Pattern
The Factory pattern provides an interface for creating objects without specifying their exact classes:
// Product interface
interface Vehicle {
readonly type: string;
start(): void;
stop(): void;
getInfo(): string;
}
// Concrete products
class Car implements Vehicle {
readonly type = 'car';
constructor(
private readonly model: string,
private readonly year: number
) {}
start(): void {
console.log(\`Starting ${this.model} car\`);
}
stop(): void {
console.log('Car stopped');
}
getInfo(): string {
return \`${this.year} ${this.model}\`;
}
}
class Motorcycle implements Vehicle {
readonly type = 'motorcycle';
constructor(
private readonly brand: string,
private readonly cc: number
) {}
start(): void {
console.log(\`Starting ${this.brand} motorcycle\`);
}
stop(): void {
console.log('Motorcycle stopped');
}
getInfo(): string {
return \`${this.brand} ${this.cc}cc\`;
}
}
class Truck implements Vehicle {
readonly type = 'truck';
constructor(
private readonly capacity: number
) {}
start(): void {
console.log('Starting truck');
}
stop(): void {
console.log('Truck stopped');
}
getInfo(): string {
return \`Truck (${this.capacity}t capacity)\`;
}
}
// Factory class
class VehicleFactory {
static createVehicle(type: 'car', model: string, year: number): Car;
static createVehicle(type: 'motorcycle', brand: string, cc: number): Motorcycle;
static createVehicle(type: 'truck', capacity: number): Truck;
static createVehicle(
type: string,
...args: unknown[]
): Vehicle {
switch (type) {
case 'car':
return new Car(args[0] as string, args[1] as number);
case 'motorcycle':
return new Motorcycle(args[0] as string, args[1] as number);
case 'truck':
return new Truck(args[0] as number);
default:
throw new Error(\`Unknown vehicle type: ${type}\`);
}
}
}
// Usage with type safety
const car = VehicleFactory.createVehicle('car', 'Tesla Model 3', 2023);
const bike = VehicleFactory.createVehicle('motorcycle', 'Harley', 1200);
const truck = VehicleFactory.createVehicle('truck', 10);
// Abstract Factory pattern with generics
interface UIComponent {
render(): string;
}
class Button implements UIComponent {
constructor(private readonly theme: string) {}
render(): string {
return \`<button class="${this.theme}-button">Click</button>\`;
}
}
class Input implements UIComponent {
constructor(private readonly theme: string) {}
render(): string {
return \`<input class="${this.theme}-input" />\`;
}
}
abstract class UIFactory {
abstract createButton(): Button;
abstract createInput(): Input;
createComponents(): UIComponent[] {
return [this.createButton(), this.createInput()];
}
}
class LightThemeFactory extends UIFactory {
createButton(): Button {
return new Button('light');
}
createInput(): Input {
return new Input('light');
}
}
class DarkThemeFactory extends UIFactory {
createButton(): Button {
return new Button('dark');
}
createInput(): Input {
return new Input('dark');
}
}
// Factory with type-safe builders
type VehicleConfig = {
car: { model: string; year: number };
motorcycle: { brand: string; cc: number };
truck: { capacity: number };
};
class TypeSafeVehicleFactory {
static create<T extends keyof VehicleConfig>(
type: T,
config: VehicleConfig[T]
): Vehicle {
switch (type) {
case 'car':
const carConfig = config as VehicleConfig['car'];
return new Car(carConfig.model, carConfig.year);
case 'motorcycle':
const bikeConfig = config as VehicleConfig['motorcycle'];
return new Motorcycle(bikeConfig.brand, bikeConfig.cc);
case 'truck':
const truckConfig = config as VehicleConfig['truck'];
return new Truck(truckConfig.capacity);
default:
throw new Error('Unknown type');
}
}
}
// Type-safe usage
const myCar = TypeSafeVehicleFactory.create('car', {
model: 'Tesla',
year: 2023
});
Tip: Use function overloads with the Factory pattern to provide strong typing for different product types while maintaining a unified interface.
Observer Pattern
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified:
// Type-safe Observer pattern
type EventMap = Record<string, unknown>;
interface Observer<T> {
update(data: T): void;
}
class Subject<T extends EventMap> {
private observers = new Map<keyof T, Set<Observer<T[keyof T]>>>();
subscribe<K extends keyof T>(
event: K,
observer: Observer<T[K]>
): () => void {
if (!this.observers.has(event)) {
this.observers.set(event, new Set());
}
this.observers.get(event)!.add(observer as Observer<T[keyof T]>);
// Return unsubscribe function
return () => {
this.observers.get(event)?.delete(observer as Observer<T[keyof T]>);
};
}
notify<K extends keyof T>(event: K, data: T[K]): void {
const observers = this.observers.get(event);
if (observers) {
observers.forEach(observer => observer.update(data));
}
}
unsubscribeAll(event?: keyof T): void {
if (event) {
this.observers.delete(event);
} else {
this.observers.clear();
}
}
}
// Define event types
type UserEvents = {
login: { userId: number; timestamp: Date };
logout: { userId: number };
update: { userId: number; fields: string[] };
};
// Create subject
const userSubject = new Subject<UserEvents>();
// Create observers
const loginLogger: Observer<UserEvents['login']> = {
update(data) {
console.log(\`User ${data.userId} logged in at ${data.timestamp}\`);
}
};
const logoutLogger: Observer<UserEvents['logout']> = {
update(data) {
console.log(\`User ${data.userId} logged out\`);
}
};
// Subscribe
const unsubscribeLogin = userSubject.subscribe('login', loginLogger);
userSubject.subscribe('logout', logoutLogger);
// Notify
userSubject.notify('login', { userId: 1, timestamp: new Date() });
userSubject.notify('logout', { userId: 1 });
// Unsubscribe
unsubscribeLogin();
// Modern EventEmitter with TypeScript
class EventEmitter<T extends EventMap> {
private listeners = new Map<keyof T, Set<(data: T[keyof T]) => void>>();
on<K extends keyof T>(event: K, listener: (data: T[K]) => void): this {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener as (data: T[keyof T]) => void);
return this;
}
off<K extends keyof T>(event: K, listener: (data: T[K]) => void): this {
this.listeners.get(event)?.delete(listener as (data: T[keyof T]) => void);
return this;
}
emit<K extends keyof T>(event: K, data: T[K]): boolean {
const listeners = this.listeners.get(event);
if (!listeners || listeners.size === 0) return false;
listeners.forEach(listener => {
try {
listener(data);
} catch (error) {
console.error(\`Error in listener for ${String(event)}:\`, error);
}
});
return true;
}
once<K extends keyof T>(event: K, listener: (data: T[K]) => void): this {
const onceWrapper = (data: T[K]) => {
listener(data);
this.off(event, onceWrapper);
};
return this.on(event, onceWrapper);
}
}
// Usage
type AppEvents = {
'user:created': { id: number; name: string };
'user:deleted': { id: number };
error: { message: string; code: number };
};
const emitter = new EventEmitter<AppEvents>();
emitter.on('user:created', (data) => {
console.log(\`New user: ${data.name} (${data.id})\`);
});
emitter.once('error', (data) => {
console.log(\`Error ${data.code}: ${data.message}\`);
});
emitter.emit('user:created', { id: 1, name: 'John' });
emitter.emit('error', { message: 'Connection failed', code: 500 });
Note: The Observer pattern is fundamental to event-driven architectures and reactive programming. TypeScript's generics ensure type safety for all events and their data.
Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable:
// Strategy interface
interface SortStrategy<T> {
sort(data: T[]): T[];
}
// Concrete strategies
class BubbleSort<T> implements SortStrategy<T> {
sort(data: T[]): T[] {
const result = [...data];
for (let i = 0; i < result.length; i++) {
for (let j = 0; j < result.length - 1 - i; j++) {
if (result[j] > result[j + 1]) {
[result[j], result[j + 1]] = [result[j + 1], result[j]];
}
}
}
return result;
}
}
class QuickSort<T> implements SortStrategy<T> {
sort(data: T[]): T[] {
if (data.length <= 1) return data;
const pivot = data[Math.floor(data.length / 2)];
const left = data.filter(x => x < pivot);
const middle = data.filter(x => x === pivot);
const right = data.filter(x => x > pivot);
return [...this.sort(left), ...middle, ...this.sort(right)];
}
}
// Context
class Sorter<T> {
constructor(private strategy: SortStrategy<T>) {}
setStrategy(strategy: SortStrategy<T>): void {
this.strategy = strategy;
}
sort(data: T[]): T[] {
return this.strategy.sort(data);
}
}
// Usage
const numbers = [5, 2, 8, 1, 9];
const sorter = new Sorter(new BubbleSort<number>());
console.log(sorter.sort(numbers));
sorter.setStrategy(new QuickSort<number>());
console.log(sorter.sort(numbers));
// Advanced strategy with function types
type ValidationRule<T> = (value: T) => boolean | string;
class Validator<T> {
private rules: ValidationRule<T>[] = [];
addRule(rule: ValidationRule<T>): this {
this.rules.push(rule);
return this;
}
validate(value: T): { valid: boolean; errors: string[] } {
const errors: string[] = [];
for (const rule of this.rules) {
const result = rule(value);
if (result === false) {
errors.push('Validation failed');
} else if (typeof result === 'string') {
errors.push(result);
}
}
return {
valid: errors.length === 0,
errors
};
}
}
// Usage
const emailValidator = new Validator<string>()
.addRule(value => value.length > 0 || 'Email is required')
.addRule(value => value.includes('@') || 'Invalid email format')
.addRule(value => value.length <= 100 || 'Email too long');
console.log(emailValidator.validate('test@example.com'));
console.log(emailValidator.validate('invalid'));
// Strategy with dependency injection
interface PaymentStrategy {
pay(amount: number): Promise<{ success: boolean; transactionId?: string }>;
}
class CreditCardPayment implements PaymentStrategy {
constructor(
private readonly cardNumber: string,
private readonly cvv: string
) {}
async pay(amount: number): Promise<{ success: boolean; transactionId?: string }> {
console.log(\`Processing credit card payment of $${amount}\`);
// Simulate payment processing
return { success: true, transactionId: 'CC-' + Date.now() };
}
}
class PayPalPayment implements PaymentStrategy {
constructor(private readonly email: string) {}
async pay(amount: number): Promise<{ success: boolean; transactionId?: string }> {
console.log(\`Processing PayPal payment of $${amount}\`);
return { success: true, transactionId: 'PP-' + Date.now() };
}
}
class CryptoPayment implements PaymentStrategy {
constructor(private readonly walletAddress: string) {}
async pay(amount: number): Promise<{ success: boolean; transactionId?: string }> {
console.log(\`Processing crypto payment of $${amount}\`);
return { success: true, transactionId: 'CR-' + Date.now() };
}
}
class PaymentProcessor {
constructor(private strategy: PaymentStrategy) {}
setStrategy(strategy: PaymentStrategy): void {
this.strategy = strategy;
}
async processPayment(amount: number): Promise<void> {
const result = await this.strategy.pay(amount);
if (result.success) {
console.log(\`Payment successful: ${result.transactionId}\`);
} else {
console.log('Payment failed');
}
}
}
// Usage
const processor = new PaymentProcessor(
new CreditCardPayment('1234-5678-9012-3456', '123')
);
await processor.processPayment(100);
processor.setStrategy(new PayPalPayment('user@example.com'));
await processor.processPayment(50);
Warning: While design patterns are powerful, don't force them into your code. Use patterns when they naturally solve a problem, not just for the sake of using them.
Exercise:
- Implement a Builder pattern for creating complex configuration objects with TypeScript, ensuring type safety for required and optional fields.
- Create a Decorator pattern implementation for adding logging, caching, and validation to class methods using TypeScript decorators.
- Build a Command pattern system for implementing undo/redo functionality with proper typing for different command types.
- Implement the Adapter pattern to create a unified interface for multiple third-party APIs with different structures.
- Create a Chain of Responsibility pattern for request handling with type-safe handler registration and data passing.
Summary
TypeScript enhances classic design patterns with strong typing, making them safer and more expressive. The Singleton pattern manages shared resources, Factory creates objects flexibly, Observer implements event-driven architectures, and Strategy encapsulates interchangeable algorithms. By leveraging TypeScript's generics, interfaces, and type inference, you can implement these patterns with compile-time safety that prevents many runtime errors.