TypeScript Design Patterns: Modern Implementation Guide
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
// @filename: index.ts
// 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
// @filename: query.sql
// 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
// @filename: index.ts
// 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
// @filename: index.ts
// 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
// @filename: index.ts
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.5)
Strategy Pattern
// @filename: index.ts
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
// @filename: query.sql
// 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
// @filename: index.ts
// 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
// @filename: index.ts
// 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.
