Design Patterns with TypeScript

Design patterns are proven solutions to common programming problems. TypeScript’s powerful type system allows us to implement these patterns with enhanced type safety, better developer experience, and compile-time guarantees.

Creational Patterns

Factory Pattern with Generics

interface Product {
name: string;
price: number;
category: string;
}
interface Book extends Product {
category: "book";
author: string;
isbn: string;
}
interface Electronics extends Product {
category: "electronics";
brand: string;
warranty: number;
}
// Factory interface with generic constraints
interface ProductFactory<T extends Product> {
create(data: Omit<T, keyof Product> & Pick<Product, "name" | "price">): T;
}
class BookFactory implements ProductFactory<Book> {
create(data: Omit<Book, keyof Product> & Pick<Product, "name" | "price">): Book {
return {
...data,
category: "book" as const,
};
}
}
// Factory registry with type safety
type FactoryMap = {
book: ProductFactory<Book>;
electronics: ProductFactory<Electronics>;
};
class ProductFactoryRegistry {
private factories: FactoryMap = {
book: new BookFactory(),
electronics: new ElectronicsFactory(),
};
createProduct<K extends keyof FactoryMap>(
type: K,
data: Parameters<FactoryMap[K]["create"]>[0]
): ReturnType<FactoryMap[K]["create"]> {
return this.factories[type].create(data);
}
}

Builder Pattern with State Tracking

interface DatabaseConfig {
host: string;
port: number;
database: string;
username: string;
password: string;
ssl: boolean;
timeout: number;
}
type RequiredFields = "host" | "database" | "username" | "password";
class DatabaseConfigBuilder<T extends Partial<DatabaseConfig> = {}> {
private config: T;
constructor(config: T = {} as T) {
this.config = config;
}
host<U extends string>(host: U): DatabaseConfigBuilder<T & { host: U }> {
return new DatabaseConfigBuilder({ ...this.config, host });
}
database<U extends string>(database: U): DatabaseConfigBuilder<T & { database: U }> {
return new DatabaseConfigBuilder({ ...this.config, database });
}
username<U extends string>(username: U): DatabaseConfigBuilder<T & { username: U }> {
return new DatabaseConfigBuilder({ ...this.config, username });
}
password<U extends string>(password: U): DatabaseConfigBuilder<T & { password: U }> {
return new DatabaseConfigBuilder({ ...this.config, password });
}
// Build only available when all required fields are set
build(this: DatabaseConfigBuilder<Record<RequiredFields, any>>): DatabaseConfig {
const defaults = { port: 5432, ssl: false, timeout: 30000 };
return { ...defaults, ...this.config } as DatabaseConfig;
}
}
// Usage - TypeScript enforces required fields
const config = new DatabaseConfigBuilder()
.host("localhost")
.database("myapp")
.username("admin")
.password("secret")
.build(); // ✅ All required fields provided

Singleton Pattern with Type Safety

abstract class Singleton<T> {
private static instances: Map<any, any> = new Map();
protected constructor() {
const constructor = this.constructor as new () => T;
if (Singleton.instances.has(constructor)) {
throw new Error(`Singleton instance already exists`);
}
Singleton.instances.set(constructor, this);
}
public static getInstance<T extends Singleton<any>>(this: new () => T): T {
if (!Singleton.instances.has(this)) {
new this();
}
return Singleton.instances.get(this);
}
}
class Logger extends Singleton<Logger> {
private logs: string[] = [];
private constructor() {
super();
}
log(message: string): void {
this.logs.push(`${new Date().toISOString()}: ${message}`);
}
getLogs(): readonly string[] {
return this.logs;
}
}

Behavioral Patterns

Observer Pattern with Type-Safe Events

interface EventMap {
userLogin: { userId: string; timestamp: Date };
userLogout: { userId: string; duration: number };
dataUpdate: { table: string; recordId: string };
}
type EventListener<T> = (data: T) => void | Promise<void>;
class Observable<T extends Record<string, any> = EventMap> {
private listeners: {
[K in keyof T]?: Set<EventListener<T[K]>>;
} = {};
on<K extends keyof T>(event: K, listener: EventListener<T[K]>): () => void {
if (!this.listeners[event]) {
this.listeners[event] = new Set();
}
this.listeners[event]!.add(listener);
return () => {
this.listeners[event]?.delete(listener);
};
}
async emit<K extends keyof T>(event: K, data: T[K]): Promise<void> {
const eventListeners = this.listeners[event];
if (!eventListeners) return;
const promises = Array.from(eventListeners).map(listener =>
Promise.resolve(listener(data))
);
await Promise.all(promises);
}
}
// Usage
class UserService extends Observable<EventMap> {
async login(userId: string): Promise<void> {
await this.emit("userLogin", { userId, timestamp: new Date() });
}
}
const userService = new UserService();
userService.on("userLogin", (data) => {
console.log(`User ${data.userId} logged in`);
// TypeScript knows data has userId and timestamp
});

Strategy Pattern with Type Constraints

interface PaymentData {
amount: number;
currency: string;
}
interface CreditCardData extends PaymentData {
cardNumber: string;
cvv: string;
}
interface PayPalData extends PaymentData {
email: string;
password: string;
}
interface PaymentResult {
success: boolean;
transactionId?: string;
error?: string;
}
interface PaymentStrategy<T extends PaymentData> {
pay(data: T): Promise<PaymentResult>;
validate(data: T): boolean;
}
class CreditCardStrategy implements PaymentStrategy<CreditCardData> {
async pay(data: CreditCardData): Promise<PaymentResult> {
if (!this.validate(data)) {
return { success: false, error: "Invalid credit card data" };
}
return {
success: true,
transactionId: `cc_${Math.random().toString(36).substr(2, 9)}`
};
}
validate(data: CreditCardData): boolean {
return data.cardNumber.length === 16 &&
data.cvv.length === 3 &&
data.amount > 0;
}
}
class PaymentProcessor {
private strategies: Map<string, PaymentStrategy<any>> = new Map();
registerStrategy<T extends PaymentData>(
name: string,
strategy: PaymentStrategy<T>
): void {
this.strategies.set(name, strategy);
}
async processPayment<T extends PaymentData>(
strategyName: string,
data: T
): Promise<PaymentResult> {
const strategy = this.strategies.get(strategyName);
if (!strategy) {
return { success: false, error: "Payment method not supported" };
}
return strategy.pay(data);
}
}

Command Pattern with Undo/Redo

interface Command {
execute(): void | Promise<void>;
undo(): void | Promise<void>;
canUndo(): boolean;
}
interface TextEditor {
insertText(position: number, text: string): void;
deleteText(position: number, length: number): string;
}
class InsertTextCommand implements Command {
constructor(
private editor: TextEditor,
private position: number,
private text: string
) {}
execute(): void {
this.editor.insertText(this.position, this.text);
}
undo(): void {
this.editor.deleteText(this.position, this.text.length);
}
canUndo(): boolean {
return true;
}
}
class CommandManager {
private history: Command[] = [];
private currentIndex: number = -1;
async executeCommand(command: Command): Promise<void> {
await command.execute();
this.history = this.history.slice(0, this.currentIndex + 1);
this.history.push(command);
this.currentIndex++;
}
async undo(): Promise<boolean> {
if (this.currentIndex >= 0) {
const command = this.history[this.currentIndex];
if (command.canUndo()) {
await command.undo();
this.currentIndex--;
return true;
}
}
return false;
}
async redo(): Promise<boolean> {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++;
const command = this.history[this.currentIndex];
await command.execute();
return true;
}
return false;
}
}

Advanced Pattern: Repository with Type Safety

interface Entity {
id: string;
createdAt: Date;
updatedAt: Date;
}
interface Repository<T extends Entity> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
create(entity: Omit<T, keyof Entity>): Promise<T>;
update(id: string, updates: Partial<Omit<T, keyof Entity>>): Promise<T>;
delete(id: string): Promise<void>;
}
type FilterOperator = "eq" | "ne" | "gt" | "lt" | "in";
interface QueryFilter<T, K extends keyof T> {
field: K;
operator: FilterOperator;
value: T[K];
}
interface QueryOptions<T extends Entity> {
filters?: QueryFilter<T, keyof T>[];
sort?: { field: keyof T; direction: "asc" | "desc" }[];
limit?: number;
}
class InMemoryRepository<T extends Entity> implements Repository<T> {
private data: Map<string, T> = new Map();
async findById(id: string): Promise<T | null> {
return this.data.get(id) || null;
}
async findAll(): Promise<T[]> {
return Array.from(this.data.values());
}
async create(entity: Omit<T, keyof Entity>): Promise<T> {
const now = new Date();
const fullEntity = {
...entity,
id: Math.random().toString(36).substr(2, 9),
createdAt: now,
updatedAt: now,
} as T;
this.data.set(fullEntity.id, fullEntity);
return fullEntity;
}
async update(id: string, updates: Partial<Omit<T, keyof Entity>>): Promise<T> {
const existing = this.data.get(id);
if (!existing) {
throw new Error(`Entity with id ${id} not found`);
}
const updated = {
...existing,
...updates,
updatedAt: new Date(),
};
this.data.set(id, updated);
return updated;
}
async delete(id: string): Promise<void> {
this.data.delete(id);
}
async findMany(options: QueryOptions<T>): Promise<T[]> {
let results = Array.from(this.data.values());
if (options.filters) {
results = results.filter(item =>
options.filters!.every(filter => this.applyFilter(item, filter))
);
}
if (options.sort) {
results.sort((a, b) => {
for (const sort of options.sort!) {
const comparison = a[sort.field] < b[sort.field] ? -1 :
a[sort.field] > b[sort.field] ? 1 : 0;
if (comparison !== 0) {
return sort.direction === "asc" ? comparison : -comparison;
}
}
return 0;
});
}
if (options.limit) {
results = results.slice(0, options.limit);
}
return results;
}
private applyFilter<K extends keyof T>(
item: T,
filter: QueryFilter<T, K>
): boolean {
const value = item[filter.field];
switch (filter.operator) {
case "eq": return value === filter.value;
case "ne": return value !== filter.value;
case "gt": return value > filter.value;
case "lt": return value < filter.value;
case "in": return Array.isArray(filter.value) && filter.value.includes(value as any);
default: return false;
}
}
}

Best Practices

1. Leverage TypeScript’s Type System

// ✅ Use discriminated unions for pattern variants
type Shape =
| { type: "circle"; radius: number }
| { type: "rectangle"; width: number; height: number };
// ✅ Use generic constraints for flexible but safe patterns
interface Factory<T extends Product> {
create(config: ProductConfig<T>): T;
}

2. Provide Type-Safe APIs

// ✅ Ensure compile-time safety
class EventEmitter<T extends Record<string, any>> {
on<K extends keyof T>(event: K, listener: (data: T[K]) => void): void {}
emit<K extends keyof T>(event: K, data: T[K]): void {}
}

3. Document Pattern Usage

/**
* Observer pattern with type-safe event handling.
*
* @example
* const observable = new Observable<{ userLogin: { userId: string } }>();
* observable.on('userLogin', (data) => console.log(data.userId));
*/
class Observable<T extends Record<string, any>> {}

Conclusion

Design patterns in TypeScript offer significant advantages:

  • Compile-time safety prevents runtime errors
  • Better IDE support with autocomplete and refactoring
  • Self-documenting code through type definitions
  • Enhanced developer experience with immediate feedback

Key principles:

  1. Use generic constraints for flexibility with safety
  2. Leverage discriminated unions for pattern variants
  3. Provide fluent, type-safe APIs
  4. Document patterns with clear examples
  5. Test implementations for type safety

Mastering these patterns helps build robust, maintainable TypeScript applications with the full power of the type system.

Next Steps

This concludes the TypeScript Advanced Patterns series. You now have the tools to create sophisticated, type-safe applications using advanced TypeScript features and proven design patterns.

Share Feedback