TypeScript Design Patterns: Modern Implementation Guide
AI-Generated Content Notice
Some code examples and technical explanations in this article were generated with AI assistance. The content has been reviewed for accuracy, but please test any code snippets in your development environment before using them.
Design patterns are essential tools for building maintainable and scalable applications. TypeScript's strong type system and object-oriented features make it perfect for implementing these patterns in a type-safe way.
In this comprehensive guide, we'll explore advanced TypeScript design patterns and architectural approaches for enterprise applications.
Key Topics
- Creational Patterns: Factory, Singleton, Builder
- Structural Patterns: Decorator, Adapter, Facade
- Behavioral Patterns: Observer, Strategy, Command
- Architectural Patterns: Repository, Unit of Work
- Advanced TypeScript Features: Generics, Decorators
1. Creational Patterns
Patterns for object creation and instantiation.
Factory Pattern
// Product interface
interface IProduct {
name: string;
price: number;
getInfo(): string;
}
// Concrete products
class PhysicalProduct implements IProduct {
constructor(
public name: string,
public price: number,
private weight: number
) {}
getInfo(): string {
return `${this.name} - $${this.price} (${this.weight}kg)`;
}
}
class DigitalProduct implements IProduct {
constructor(
public name: string,
public price: number,
private downloadSize: number
) {}
getInfo(): string {
return `${this.name} - $${this.price} (${this.downloadSize}MB)`;
}
}
// Factory class
class ProductFactory {
static createProduct(type: 'physical' | 'digital', data: {
name: string;
price: number;
weight?: number;
downloadSize?: number;
}): IProduct {
switch (type) {
case 'physical':
return new PhysicalProduct(data.name, data.price, data.weight!);
case 'digital':
return new DigitalProduct(data.name, data.price, data.downloadSize!);
default:
throw new Error('Invalid product type');
}
}
}
// Usage
const book = ProductFactory.createProduct('physical', {
name: 'TypeScript Design Patterns',
price: 29.99,
weight: 0.5
});
const ebook = ProductFactory.createProduct('digital', {
name: 'TypeScript Design Patterns - PDF',
price: 19.99,
downloadSize: 15
});
Singleton Pattern with Dependency Injection
// Singleton service
class DatabaseConnection {
private static instance: DatabaseConnection;
private constructor(private connectionString: string) {}
static getInstance(connectionString: string): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection(connectionString);
}
return DatabaseConnection.instance;
}
query(sql: string): Promise<any> {
// Implementation
return Promise.resolve([]);
}
}
// Dependency injection container
class Container {
private static services: Map<string, any> = new Map();
static register<T>(token: string, instance: T): void {
Container.services.set(token, instance);
}
static resolve<T>(token: string): T {
const service = Container.services.get(token);
if (!service) {
throw new Error(`Service ${token} not found`);
}
return service;
}
}
// Register services
Container.register('db', DatabaseConnection.getInstance('connection_string'));
// Usage with dependency injection
class UserService {
private db: DatabaseConnection;
constructor() {
this.db = Container.resolve('db');
}
async getUsers(): Promise<any[]> {
return this.db.query('SELECT * FROM users');
}
}
2. Structural Patterns
Patterns for composing objects and classes.
Decorator Pattern
// Method decorator
function log(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const original = descriptor.value;
descriptor.value = async function(...args: any[]) {
console.log(`Calling ${propertyKey} with args:`, args);
const start = performance.now();
try {
const result = await original.apply(this, args);
const end = performance.now();
console.log(`${propertyKey} completed in ${end - start}ms`);
return result;
} catch (error) {
console.error(`${propertyKey} failed:`, error);
throw error;
}
};
return descriptor;
}
// Class decorator
function injectable() {
return function(constructor: Function) {
// Register in DI container
Container.register(constructor.name, new constructor());
};
}
// Property decorator
function required(target: any, propertyKey: string) {
let value: any;
const getter = function() {
if (value === undefined) {
throw new Error(`${propertyKey} is required`);
}
return value;
};
const setter = function(newVal: any) {
value = newVal;
};
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
}
// Usage
@injectable()
class UserController {
@required
private userService: UserService;
@log
async getUsers(): Promise<any[]> {
return this.userService.getUsers();
}
}
Adapter Pattern
// Third-party payment service
interface ILegacyPayment {
processPayment(amount: number): Promise<boolean>;
}
class LegacyPaymentService implements ILegacyPayment {
async processPayment(amount: number): Promise<boolean> {
// Legacy implementation
return true;
}
}
// Modern payment interface
interface IModernPayment {
pay(amount: number, currency: string): Promise<{
success: boolean;
transactionId: string;
}>;
}
// Adapter
class PaymentAdapter implements IModernPayment {
constructor(private legacyService: ILegacyPayment) {}
async pay(amount: number, currency: string): Promise<{
success: boolean;
transactionId: string;
}> {
const success = await this.legacyService.processPayment(amount);
return {
success,
transactionId: Date.now().toString()
};
}
}
// Usage
const legacyService = new LegacyPaymentService();
const modernPayment = new PaymentAdapter(legacyService);
3. Behavioral Patterns
Patterns for communication between objects.
Observer Pattern
interface IObserver<T> {
update(data: T): void;
}
class Observable<T> {
private observers: IObserver<T>[] = [];
subscribe(observer: IObserver<T>): void {
this.observers.push(observer);
}
unsubscribe(observer: IObserver<T>): void {
const index = this.observers.indexOf(observer);
if (index > -1) {
this.observers.splice(index, 1);
}
}
notify(data: T): void {
this.observers.forEach(observer => observer.update(data));
}
}
// Example implementation
interface IStockUpdate {
symbol: string;
price: number;
}
class StockMarket extends Observable<IStockUpdate> {
private prices: Map<string, number> = new Map();
updateStock(symbol: string, price: number): void {
this.prices.set(symbol, price);
this.notify({ symbol, price });
}
}
class StockTrader implements IObserver<IStockUpdate> {
constructor(private name: string) {}
update(data: IStockUpdate): void {
console.log(
`${this.name} received update: ${data.symbol} = $${data.price}`
);
}
}
// Usage
const market = new StockMarket();
const trader1 = new StockTrader('Trader 1');
const trader2 = new StockTrader('Trader 2');
market.subscribe(trader1);
market.subscribe(trader2);
market.updateStock('AAPL', 150.50);
Strategy Pattern
interface IDiscountStrategy {
calculate(amount: number): number;
}
class PercentageDiscount implements IDiscountStrategy {
constructor(private percentage: number) {}
calculate(amount: number): number {
return amount * (1 - this.percentage / 100);
}
}
class FixedDiscount implements IDiscountStrategy {
constructor(private amount: number) {}
calculate(amount: number): number {
return Math.max(0, amount - this.amount);
}
}
class ShoppingCart {
private items: { name: string; price: number }[] = [];
private discountStrategy: IDiscountStrategy;
setDiscountStrategy(strategy: IDiscountStrategy): void {
this.discountStrategy = strategy;
}
addItem(name: string, price: number): void {
this.items.push({ name, price });
}
getTotal(): number {
const subtotal = this.items.reduce((sum, item) => sum + item.price, 0);
return this.discountStrategy
? this.discountStrategy.calculate(subtotal)
: subtotal;
}
}
// Usage
const cart = new ShoppingCart();
cart.addItem('Item 1', 100);
cart.addItem('Item 2', 50);
// Apply 20% discount
cart.setDiscountStrategy(new PercentageDiscount(20));
console.log(cart.getTotal()); // 120
// Apply $30 fixed discount
cart.setDiscountStrategy(new FixedDiscount(30));
console.log(cart.getTotal()); // 120
4. Architectural Patterns
Patterns for organizing application architecture.
Repository Pattern
// Entity interface
interface IEntity {
id: number;
}
// Generic repository interface
interface IRepository<T extends IEntity> {
getById(id: number): Promise<T | null>;
getAll(): Promise<T[]>;
create(entity: Omit<T, 'id'>): Promise<T>;
update(id: number, entity: Partial<T>): Promise<T>;
delete(id: number): Promise<boolean>;
}
// Generic base repository implementation
abstract class BaseRepository<T extends IEntity> implements IRepository<T> {
constructor(protected tableName: string) {}
async getById(id: number): Promise<T | null> {
const db = Container.resolve<DatabaseConnection>('db');
const result = await db.query(
`SELECT * FROM ${this.tableName} WHERE id = ?`,
[id]
);
return result[0] || null;
}
async getAll(): Promise<T[]> {
const db = Container.resolve<DatabaseConnection>('db');
return db.query(`SELECT * FROM ${this.tableName}`);
}
async create(entity: Omit<T, 'id'>): Promise<T> {
const db = Container.resolve<DatabaseConnection>('db');
const result = await db.query(
`INSERT INTO ${this.tableName} SET ?`,
[entity]
);
return { ...entity, id: result.insertId } as T;
}
async update(id: number, entity: Partial<T>): Promise<T> {
const db = Container.resolve<DatabaseConnection>('db');
await db.query(
`UPDATE ${this.tableName} SET ? WHERE id = ?`,
[entity, id]
);
return this.getById(id) as Promise<T>;
}
async delete(id: number): Promise<boolean> {
const db = Container.resolve<DatabaseConnection>('db');
const result = await db.query(
`DELETE FROM ${this.tableName} WHERE id = ?`,
[id]
);
return result.affectedRows > 0;
}
}
// Example implementation
interface IUser extends IEntity {
id: number;
name: string;
email: string;
}
class UserRepository extends BaseRepository<IUser> {
constructor() {
super('users');
}
// Additional user-specific methods
async findByEmail(email: string): Promise<IUser | null> {
const db = Container.resolve<DatabaseConnection>('db');
const result = await db.query(
`SELECT * FROM ${this.tableName} WHERE email = ?`,
[email]
);
return result[0] || null;
}
}
5. Advanced TypeScript Features
Leverage TypeScript's type system for better patterns.
Advanced Generics
// Type-safe event emitter
type EventMap = {
'user:created': { id: number; name: string };
'user:updated': { id: number; changes: Partial<IUser> };
'user:deleted': { id: number };
}
class TypedEventEmitter<T extends Record<string, any>> {
private listeners: Partial<{
[K in keyof T]: ((data: T[K]) => void)[];
}> = {};
on<K extends keyof T>(event: K, listener: (data: T[K]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
if (!this.listeners[event]) return;
this.listeners[event]!.forEach(listener => listener(data));
}
}
// Usage
const emitter = new TypedEventEmitter<EventMap>();
emitter.on('user:created', data => {
console.log(`User created: ${data.name}`);
});
emitter.emit('user:created', {
id: 1,
name: 'John Doe'
});
Utility Types
// Deep partial type
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? DeepPartial<T[P]>
: T[P];
};
// Immutable type
type Immutable<T> = {
readonly [P in keyof T]: T[P] extends object
? Immutable<T[P]>
: T[P];
};
// Function type utilities
type AsyncFunction<T extends (...args: any[]) => any> = (
...args: Parameters<T>
) => Promise<ReturnType<T>>;
type Memoized<T extends (...args: any[]) => any> = {
(...args: Parameters<T>): ReturnType<T>;
clear(): void;
};
// Implementation example
function memoize<T extends (...args: any[]) => any>(
fn: T
): Memoized<T> {
const cache = new Map<string, ReturnType<T>>();
const memoized = (...args: Parameters<T>): ReturnType<T> => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = fn(...args);
cache.set(key, result);
return result;
};
memoized.clear = () => cache.clear();
return memoized;
}
// Usage
interface IConfig {
api: {
url: string;
key: string;
};
cache: {
ttl: number;
};
}
const updateConfig = (config: DeepPartial<IConfig>): void => {
// Implementation
};
const getConfig = memoize(() => ({
api: {
url: 'https://api.example.com',
key: 'secret'
},
cache: {
ttl: 3600
}
}));
Best Practices
-
Pattern Selection
- Choose patterns based on requirements
- Consider maintainability
- Avoid over-engineering
- Document pattern usage
-
Type Safety
- Use strict TypeScript configuration
- Leverage type inference
- Create custom type guards
- Use utility types
-
Code Organization
- Follow SOLID principles
- Use dependency injection
- Implement proper error handling
- Write unit tests
-
Performance
- Consider memory usage
- Optimize for runtime
- Use lazy loading
- Implement caching
Conclusion
TypeScript design patterns provide powerful tools for building maintainable and scalable applications. By understanding and applying these patterns, you can:
- Write more maintainable code
- Improve code reusability
- Ensure type safety
- Create flexible architectures
Remember to choose patterns that fit your specific needs and avoid over-complicating your codebase. Focus on writing clean, readable code that follows TypeScript best practices.