Advanced TypeScript Design Patterns for Enterprise Applications
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.