TypeScript Design Patterns Every Developer Should Know
Implementing Singleton, Factory, Observer, and Decorator patterns in TypeScript with real-world examples, complete type safety, and best practices.
- Singleton: Managing global state with type safety
- Factory: Creating objects with flexible initialization
- Observer: Event-driven architectures with strong typing
- Decorator: Enhancing functionality without modification
- Builder: Constructing complex objects step-by-step
Why Design Patterns Matter in TypeScript
Design patterns are reusable solutions to common software design problems. TypeScript's type system makes these patterns even more powerful by catching errors at compile time and providing excellent IDE support.
This article covers five essential patterns I use regularly in production TypeScript applications. Each pattern includes a practical real-world example, complete implementation, and discussion of trade-offs.
1. Singleton Pattern
The Singleton pattern ensures a class has only one instance and provides global access to it. This is useful for managing shared resources like database connections, configuration managers, or logging systems.
Basic Implementation
// DatabaseConnection.ts
class DatabaseConnection {
private static instance: DatabaseConnection;
private connection: any;
// Private constructor prevents direct instantiation
private constructor() {
this.connection = this.initializeConnection();
}
public static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
private initializeConnection() {
console.log('Initializing database connection...');
return { connected: true };
}
public query(sql: string) {
console.log(`Executing: ${sql}`);
return this.connection;
}
}
// Usage
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
console.log(db1 === db2); // true - same instance
Thread-Safe Singleton with Generics
// Singleton.ts
export abstract class Singleton<T> {
private static instances = new Map<any, any>();
protected constructor() {
const constructor = this.constructor;
if (Singleton.instances.has(constructor)) {
throw new Error('Singleton instance already exists!');
}
Singleton.instances.set(constructor, this);
}
public static getInstance<T>(this: new () => T): T {
if (!Singleton.instances.has(this)) {
Singleton.instances.set(this, new this());
}
return Singleton.instances.get(this);
}
}
// Implementation
class ConfigManager extends Singleton<ConfigManager> {
private config: Record<string, any> = {};
private constructor() {
super();
this.loadConfig();
}
private loadConfig() {
this.config = {
apiUrl: process.env.API_URL,
apiKey: process.env.API_KEY,
};
}
public get(key: string): any {
return this.config[key];
}
public set(key: string, value: any): void {
this.config[key] = value;
}
}
// Usage
const config = ConfigManager.getInstance();
console.log(config.get('apiUrl'));
When to Use Singleton
- Database connection pools
- Application configuration managers
- Logging services
- Cache managers
- Testing (makes mocking difficult)
- When you need multiple instances
- Stateless utilities (use static methods instead)
2. Factory Pattern
The Factory pattern provides an interface for creating objects without specifying their exact classes. This is particularly useful when the creation logic is complex or when you want to decouple object creation from usage.
Simple Factory
// types.ts
interface Notification {
send(message: string): void;
}
// EmailNotification.ts
class EmailNotification implements Notification {
constructor(private recipient: string) {}
send(message: string): void {
console.log(`Sending email to ${this.recipient}: ${message}`);
}
}
// SMSNotification.ts
class SMSNotification implements Notification {
constructor(private phoneNumber: string) {}
send(message: string): void {
console.log(`Sending SMS to ${this.phoneNumber}: ${message}`);
}
}
// PushNotification.ts
class PushNotification implements Notification {
constructor(private deviceId: string) {}
send(message: string): void {
console.log(`Sending push to device ${this.deviceId}: ${message}`);
}
}
// NotificationFactory.ts
type NotificationType = 'email' | 'sms' | 'push';
class NotificationFactory {
static create(
type: NotificationType,
recipient: string
): Notification {
switch (type) {
case 'email':
return new EmailNotification(recipient);
case 'sms':
return new SMSNotification(recipient);
case 'push':
return new PushNotification(recipient);
default:
throw new Error(`Unknown notification type: ${type}`);
}
}
}
// Usage
const notification = NotificationFactory.create('email', 'user@example.com');
notification.send('Welcome to our platform!');
Abstract Factory Pattern
// UIFactory.ts
interface Button {
render(): string;
onClick(handler: () => void): void;
}
interface Input {
render(): string;
getValue(): string;
}
// Concrete implementations
class MaterialButton implements Button {
render(): string {
return '<button class="material-btn">Click Me</button>';
}
onClick(handler: () => void): void {
console.log('Material button clicked');
handler();
}
}
class MaterialInput implements Input {
private value = '';
render(): string {
return '<input class="material-input" />';
}
getValue(): string {
return this.value;
}
}
class BootstrapButton implements Button {
render(): string {
return '<button class="btn btn-primary">Click Me</button>';
}
onClick(handler: () => void): void {
console.log('Bootstrap button clicked');
handler();
}
}
class BootstrapInput implements Input {
private value = '';
render(): string {
return '<input class="form-control" />';
}
getValue(): string {
return this.value;
}
}
// Abstract factory
interface UIFactory {
createButton(): Button;
createInput(): Input;
}
class MaterialUIFactory implements UIFactory {
createButton(): Button {
return new MaterialButton();
}
createInput(): Input {
return new MaterialInput();
}
}
class BootstrapUIFactory implements UIFactory {
createButton(): Button {
return new BootstrapButton();
}
createInput(): Input {
return new BootstrapInput();
}
}
// Usage
function renderForm(factory: UIFactory) {
const button = factory.createButton();
const input = factory.createInput();
console.log(input.render());
console.log(button.render());
}
// Switch themes by changing factory
const materialFactory = new MaterialUIFactory();
renderForm(materialFactory);
const bootstrapFactory = new BootstrapUIFactory();
renderForm(bootstrapFactory);
3. Observer Pattern
The Observer pattern defines a one-to-many dependency between objects. When one object changes state, all its dependents are notified automatically. This is the foundation of event-driven programming.
Type-Safe Event Emitter
// EventEmitter.ts
type EventHandler<T = any> = (data: T) => void;
interface EventMap {
[event: string]: any;
}
class TypedEventEmitter<T extends EventMap> {
private listeners: {
[K in keyof T]?: EventHandler<T[K]>[];
} = {};
on<K extends keyof T>(event: K, handler: EventHandler<T[K]>): () => void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(handler);
// Return unsubscribe function
return () => this.off(event, handler);
}
off<K extends keyof T>(event: K, handler: EventHandler<T[K]>): void {
const handlers = this.listeners[event];
if (handlers) {
this.listeners[event] = handlers.filter((h) => h !== handler) as any;
}
}
emit<K extends keyof T>(event: K, data: T[K]): void {
const handlers = this.listeners[event];
if (handlers) {
handlers.forEach((handler) => handler(data));
}
}
once<K extends keyof T>(event: K, handler: EventHandler<T[K]>): void {
const onceHandler: EventHandler<T[K]> = (data) => {
handler(data);
this.off(event, onceHandler);
};
this.on(event, onceHandler);
}
}
// Usage with typed events
interface UserEvents {
login: { userId: string; timestamp: number };
logout: { userId: string };
update: { userId: string; field: string; value: any };
}
class UserManager extends TypedEventEmitter<UserEvents> {
login(userId: string) {
// Perform login logic
this.emit('login', { userId, timestamp: Date.now() });
}
logout(userId: string) {
// Perform logout logic
this.emit('logout', { userId });
}
}
const userManager = new UserManager();
// Type-safe event handling
userManager.on('login', (data) => {
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
userManager.on('logout', (data) => {
console.log(`User ${data.userId} logged out`);
});
// Unsubscribe pattern
const unsubscribe = userManager.on('login', (data) => {
console.log('Login event received');
});
// Later...
unsubscribe(); // Stop listening
Observable State Management
// Observable.ts
type Subscriber<T> = (value: T) => void;
class Observable<T> {
private value: T;
private subscribers = new Set<Subscriber<T>>();
constructor(initialValue: T) {
this.value = initialValue;
}
getValue(): T {
return this.value;
}
setValue(newValue: T): void {
if (this.value !== newValue) {
this.value = newValue;
this.notify();
}
}
subscribe(subscriber: Subscriber<T>): () => void {
this.subscribers.add(subscriber);
subscriber(this.value); // Immediately call with current value
return () => this.subscribers.delete(subscriber);
}
private notify(): void {
this.subscribers.forEach((subscriber) => subscriber(this.value));
}
}
// Usage
const count = new Observable(0);
const unsubscribe = count.subscribe((value) => {
console.log(`Count changed to: ${value}`);
});
count.setValue(1); // Logs: "Count changed to: 1"
count.setValue(2); // Logs: "Count changed to: 2"
unsubscribe();
count.setValue(3); // Nothing logged
4. Decorator Pattern
Decorators attach additional responsibilities to objects dynamically. TypeScript's decorator syntax makes this pattern particularly elegant.
Method Decorators
// decorators.ts
function Logger(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyKey} with:`, args);
const result = originalMethod.apply(this, args);
console.log(`Result:`, result);
return result;
};
return descriptor;
}
function Memoize(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
const cache = new Map<string, any>();
descriptor.value = function (...args: any[]) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`Cache hit for ${propertyKey}`);
return cache.get(key);
}
const result = originalMethod.apply(this, args);
cache.set(key, result);
return result;
};
return descriptor;
}
function Retry(maxAttempts: number = 3) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
let lastError: any;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await originalMethod.apply(this, args);
} catch (error) {
lastError = error;
console.log(`Attempt ${attempt} failed, retrying...`);
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
}
throw lastError;
};
return descriptor;
};
}
// Usage
class Calculator {
@Logger
add(a: number, b: number): number {
return a + b;
}
@Memoize
fibonacci(n: number): number {
if (n <= 1) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
}
class APIClient {
@Retry(3)
async fetchData(url: string): Promise<any> {
const response = await fetch(url);
if (!response.ok) throw new Error('Request failed');
return response.json();
}
}
const calc = new Calculator();
calc.add(2, 3); // Logs method call and result
calc.fibonacci(10); // Uses memoization for performance
5. Builder Pattern
The Builder pattern constructs complex objects step by step, providing a fluent interface for object creation.
// QueryBuilder.ts
interface QueryConfig {
table: string;
fields?: string[];
where?: Record<string, any>;
orderBy?: { field: string; direction: 'ASC' | 'DESC' };
limit?: number;
offset?: number;
}
class QueryBuilder {
private config: QueryConfig;
constructor(table: string) {
this.config = { table };
}
select(...fields: string[]): this {
this.config.fields = fields;
return this;
}
where(conditions: Record<string, any>): this {
this.config.where = conditions;
return this;
}
orderBy(field: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
this.config.orderBy = { field, direction };
return this;
}
limit(limit: number): this {
this.config.limit = limit;
return this;
}
offset(offset: number): this {
this.config.offset = offset;
return this;
}
build(): string {
const { table, fields, where, orderBy, limit, offset } = this.config;
let query = `SELECT ${fields?.join(', ') || '*'} FROM ${table}`;
if (where) {
const conditions = Object.entries(where)
.map(([key, value]) => `${key} = '${value}'`)
.join(' AND ');
query += ` WHERE ${conditions}`;
}
if (orderBy) {
query += ` ORDER BY ${orderBy.field} ${orderBy.direction}`;
}
if (limit) {
query += ` LIMIT ${limit}`;
}
if (offset) {
query += ` OFFSET ${offset}`;
}
return query;
}
}
// Usage
const query = new QueryBuilder('users')
.select('id', 'name', 'email')
.where({ status: 'active' })
.orderBy('created_at', 'DESC')
.limit(10)
.build();
console.log(query);
// SELECT id, name, email FROM users WHERE status = 'active' ORDER BY created_at DESC LIMIT 10
Choosing the Right Pattern
- Singleton: When you need exactly one instance (logger, config, connection pool)
- Factory: When object creation is complex or depends on runtime conditions
- Observer: When you need reactive, event-driven behavior
- Decorator: When you want to add functionality without modifying classes
- Builder: When constructing objects requires many optional parameters
Key Takeaways
Type safety is crucial. TypeScript's type system catches pattern implementation errors at compile time.
Don't overuse patterns. Apply patterns when they solve real problems, not because they're trendy.
Favor composition over inheritance. Many patterns work better with composition in modern TypeScript.
Consider testability. Some patterns (like Singleton) can make testing harder. Plan accordingly.