TypeScript has evolved from a simple type checker to a powerful tool for building large-scale applications. As enterprise applications grow in complexity, the patterns and practices we use become crucial for maintainability, scalability, and team productivity. In this article, we'll explore advanced TypeScript patterns that have proven effective in enterprise environments.
1. Dependency Injection Patterns
Dependency injection is fundamental to building testable and maintainable enterprise applications. TypeScript's decorator system and metadata reflection capabilities make it possible to implement sophisticated DI patterns.
Constructor Injection with Decorators
// Service decorator for automatic registration
function Injectable(token?: string) {
return function (constructor: any) {
Reflect.defineMetadata('injectable', true, constructor);
Reflect.defineMetadata('token', token || constructor.name, constructor);
};
}
// Inject decorator for parameter injection
function Inject(token: string) {
return function (target: any, propertyKey: string | symbol | undefined, parameterIndex: number) {
const existingTokens = Reflect.getMetadata('inject:tokens', target) || [];
existingTokens[parameterIndex] = token;
Reflect.defineMetadata('inject:tokens', existingTokens, target);
};
}
@Injectable()
class UserService {
constructor(
@Inject('DatabaseConnection') private db: DatabaseConnection,
@Inject('Logger') private logger: Logger
) {}
async getUser(id: string): Promise<User> {
this.logger.info(`Fetching user ${id}`);
return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
}
}
2. Advanced Generic Patterns
Generics in TypeScript allow us to create reusable, type-safe components. Here are some advanced patterns that are particularly useful in enterprise applications.
Generic Repository Pattern
interface BaseEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
interface Repository<T extends BaseEntity> {
findById(id: string): Promise<T | null>;
findAll(filter?: Partial<T>): Promise<T[]>;
create(entity: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T>;
update(id: string, updates: Partial<T>): Promise<T>;
delete(id: string): Promise<boolean>;
}
class BaseRepository<T extends BaseEntity> implements Repository<T> {
constructor(private entityName: string) {}
async findById(id: string): Promise<T | null> {
// Implementation here
return null;
}
async findAll(filter?: Partial<T>): Promise<T[]> {
// Implementation here
return [];
}
// ... other methods
}
// Usage
interface User extends BaseEntity {
email: string;
name: string;
}
const userRepository: Repository<User> = new BaseRepository<User>('users');
3. Event-Driven Architecture Patterns
Event-driven patterns help decouple components and make systems more scalable. TypeScript's type system can ensure type safety across event boundaries.
Type-Safe Event Bus
// Define event types
interface EventMap {
'user:created': { userId: string; email: string };
'user:updated': { userId: string; changes: Partial<User> };
'order:placed': { orderId: string; userId: string; total: number };
}
type EventHandler<T> = (data: T) => void | Promise<void>;
class TypedEventBus {
private handlers = new Map<string, Set<EventHandler<any>>>();
on<K extends keyof EventMap>(
event: K,
handler: EventHandler<EventMap[K]>
): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
}
emit<K extends keyof EventMap>(
event: K,
data: EventMap[K]
): void {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
eventHandlers.forEach(handler => handler(data));
}
}
}
// Usage with full type safety
const eventBus = new TypedEventBus();
eventBus.on('user:created', (data) => {
// data is automatically typed as { userId: string; email: string }
console.log(`New user created: ${data.email}`);
});
eventBus.emit('user:created', {
userId: '123',
email: 'user@example.com'
});
4. Factory and Builder Patterns
Factory and builder patterns help manage object creation complexity, especially when dealing with objects that have many optional parameters or complex initialization logic.
Generic Factory Pattern
interface ConfigurableService {
configure(config: any): void;
}
class ServiceFactory {
private static services = new Map<string, new() => any>();
static register<T extends ConfigurableService>(
name: string,
serviceClass: new() => T
): void {
this.services.set(name, serviceClass);
}
static create<T extends ConfigurableService>(
name: string,
config?: any
): T {
const ServiceClass = this.services.get(name);
if (!ServiceClass) {
throw new Error(`Service ${name} not registered`);
}
const service = new ServiceClass();
if (config) {
service.configure(config);
}
return service as T;
}
}
// Registration
ServiceFactory.register('emailService', EmailService);
ServiceFactory.register('smsService', SMSService);
// Usage
const emailService = ServiceFactory.create('emailService', {
apiKey: 'your-api-key',
defaultFrom: 'noreply@example.com'
});
5. Error Handling Patterns
Robust error handling is crucial in enterprise applications. TypeScript's union types and discriminated unions provide powerful tools for type-safe error handling.
Result Pattern for Error Handling
type Result<T, E = Error> = Success<T> | Failure<E>;
interface Success<T> {
success: true;
data: T;
}
interface Failure<E> {
success: false;
error: E;
}
function success<T>(data: T): Success<T> {
return { success: true, data };
}
function failure<E>(error: E): Failure<E> {
return { success: false, error };
}
// Usage in service methods
class UserService {
async getUser(id: string): Promise<Result<User, 'NOT_FOUND' | 'DATABASE_ERROR'>> {
try {
const user = await this.userRepository.findById(id);
if (!user) {
return failure('NOT_FOUND' as const);
}
return success(user);
} catch (error) {
return failure('DATABASE_ERROR' as const);
}
}
}
// Type-safe error handling
const result = await userService.getUser('123');
if (result.success) {
// result.data is typed as User
console.log(result.data.email);
} else {
// result.error is typed as 'NOT_FOUND' | 'DATABASE_ERROR'
if (result.error === 'NOT_FOUND') {
return res.status(404).json({ message: 'User not found' });
}
}
Conclusion
These TypeScript patterns provide a solid foundation for building maintainable, scalable enterprise applications. By leveraging TypeScript's type system, decorators, and advanced features, we can create code that is not only robust and performant but also easy to understand and modify.
The key to successful enterprise application development is choosing the right patterns for your specific use case. Start with simpler patterns and gradually introduce more advanced ones as your application grows in complexity.
Remember that these patterns are tools to solve specific problems. Always consider the trade-offs between complexity, maintainability, and performance when deciding which patterns to implement in your projects.