TypeScript Design Patterns: Factory, Singleton, Builder, and More
Design patterns are reusable solutions to common software design problems. While the Gang of Four (GoF) patterns were originally described with statically typed languages in mind, TypeScript’s type system — with generics, interfaces, access modifiers, and structural typing — makes these patterns more expressive and safer than their JavaScript counterparts. This guide implements creational, structural, and behavioral patterns in idiomatic TypeScript, leveraging the type system to enforce constraints at compile time.
Creational Patterns
Singleton
The Singleton pattern ensures a class has exactly one instance and provides a global point of access. In TypeScript, we enforce this by making the constructor private and accessing the instance via a static method.
class Database {
private static instance: Database;
private constructor() { /* initialize connection */ }
static getInstance(): Database {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
query(sql: string): unknown[] { return []; }
---The private constructor prevents external instantiation. TypeScript enforces this at compile time — new Database() outside the class causes a type error.
Modern Singleton Pattern with Modules
The module system provides a simpler singleton mechanism without class boilerplate. A module-level variable with an exported initializer achieves the same guarantee:
// db.ts
let db: Database | undefined;
export function getDatabase(): Database {
if (!db) {
db = new Database(process.env.DATABASE_URL);
}
return db;
---This approach avoids static class members, works naturally with tree-shaking, and is easier to mock in tests (you can replace getDatabase with vi.mock in Vitest). For frameworks with dependency injection (NestJS, Angular), the DI container manages singleton lifecycle automatically, making explicit singleton patterns unnecessary.
let db: Database;
export function getDb(): Database {
if (!db) db = new Database();
return db;
---Factory Method
The Factory Method defines an interface for creating objects but lets subclasses decide which class to instantiate. TypeScript’s generics and union types make the factory pattern exceptionally safe.
interface Button {
render(): void;
onClick(cb: () => void): void;
---
class WindowsButton implements Button { /* Windows impl */ }
class MacButton implements Button { /* Mac impl */ }
class Dialog {
createButton(): Button {
return process.platform === 'darwin'
? new MacButton()
: new WindowsButton();
}
---A refined version uses a registry pattern with typed constructors:
type ButtonConstructor = new () => Button;
const buttonRegistry = new Map<string, ButtonConstructor>();
function registerButton(os: string, ctor: ButtonConstructor) {
buttonRegistry.set(os, ctor);
---
function createButton(os: string): Button {
const Ctor = buttonRegistry.get(os);
if (!Ctor) throw new Error(`No button for ${os}`);
return new Ctor();
---Builder
The Builder pattern constructs complex objects step by step. TypeScript’s type system ensures that required steps cannot be skipped and properties cannot be accessed before initialization.
class QueryBuilder {
private select: string[] = [];
private from?: string;
private where: string[] = [];
addSelect(columns: string[]): this {
this.select.push(...columns);
return this;
}
addFrom(table: string): this {
this.from = table;
return this;
}
addWhere(condition: string): this {
this.where.push(condition);
return this;
}
build(): string {
if (!this.select.length || !this.from) {
throw new Error('SELECT and FROM are required');
}
const clauses = [
`SELECT ${this.select.join(', ')}`,
`FROM ${this.from}`,
];
if (this.where.length) {
clauses.push(`WHERE ${this.where.join(' AND ')}`);
}
return clauses.join(' ');
}
---The builder method pattern is widely used in ORMs (Prisma, TypeORM) and HTTP client configurations.
Structural Patterns
Adapter
The Adapter pattern allows incompatible interfaces to work together. TypeScript’s interface system makes the adapter contract explicit.
interface Logger {
log(message: string): void;
---
class ConsoleLogger implements Logger {
log(message: string): void { console.log(message); }
---
// Third-party library with incompatible interface
class WinstonLogger {
info(msg: string): void { /* winston impl */ }
---
class WinstonAdapter implements Logger {
constructor(private winston: WinstonLogger) {}
log(message: string): void { this.winston.info(message); }
---Decorator Pattern (Structural)
The structural Decorator pattern (distinct from TypeScript decorators syntax) attaches additional responsibilities to an object dynamically. TypeScript interfaces maintain type compatibility throughout the decorator chain.
interface DataSource {
write(data: string): void;
read(): string;
---
class FileDataSource implements DataSource {
constructor(private filename: string) {}
write(data: string): void { /* write to file */ }
read(): string { return ''; }
---
class CompressionDecorator implements DataSource {
constructor(private source: DataSource) {}
write(data: string): void { this.source.write(this.compress(data)); }
read(): string { return this.decompress(this.source.read()); }
private compress(data: string): string { return data; }
private decompress(data: string): string { return data; }
---Proxy
The Proxy pattern provides a surrogate or placeholder for another object. TypeScript’s structural typing ensures the proxy conforms to the same interface as the real subject.
interface Image {
display(): void;
---
class RealImage implements Image {
constructor(private filename: string) {
this.loadFromDisk();
}
private loadFromDisk(): void { /* heavy loading */ }
display(): void { /* display */ }
---
class ImageProxy implements Image {
private realImage?: RealImage;
constructor(private filename: string) {}
display(): void {
if (!this.realImage) {
this.realImage = new RealImage(this.filename);
}
this.realImage.display();
}
---Behavioral Patterns
Strategy
The Strategy pattern defines a family of algorithms and makes them interchangeable. TypeScript interfaces provide the contract, and union types can constrain valid strategies.
interface SortStrategy {
sort<T>(items: T[]): T[];
---
class QuickSort implements SortStrategy {
sort<T>(items: T[]): T[] { /* quicksort */ return items; }
---
class MergeSort implements SortStrategy {
sort<T>(items: T[]): T[] { /* mergesort */ return items; }
---
class Sorter {
constructor(private strategy: SortStrategy) {}
sort<T>(items: T[]): T[] { return this.strategy.sort(items); }
---The Strategy pattern pairs naturally with dependency injection — you can inject different strategies in production vs. tests without modifying the client code.
Observer
The Observer pattern defines a one-to-many dependency between objects. TypeScript’s generics make the notification type-safe.
interface Observer<T> {
update(data: T): void;
---
class Subject<T> {
private observers: Set<Observer<T>> = new Set();
attach(observer: Observer<T>): void { this.observers.add(observer); }
detach(observer: Observer<T>): void { this.observers.delete(observer); }
notify(data: T): void { this.observers.forEach(o => o.update(data)); }
---The generic parameter T ensures that the observer’s update method receives the same type as the subject’s notify method. This prevents data mismatches that would require runtime checking in JavaScript.
Chain of Responsibility
Passes requests along a chain of handlers. TypeScript’s generics and discriminated unions ensure each handler processes the correct message type.
interface Handler<T, R> {
setNext(handler: Handler<T, R>): Handler<T, R>;
handle(request: T): R | undefined;
---
abstract class AbstractHandler<T, R> implements Handler<T, R> {
private next?: Handler<T, R>;
setNext(handler: Handler<T, R>): Handler<T, R> {
this.next = handler;
return handler;
}
handle(request: T): R | undefined {
return this.next?.handle(request);
}
---This pattern is used extensively in middleware architectures (Express, Koa, Redux) and validation pipelines where a request passes through a series of checks before being processed.
Patterns Specific to TypeScript
Type-Safe Event Emitter
Uses generics and mapped types to correlate event names with payload types:
type EventMap = {
userLogin: { userId: string };
error: { message: string; code: number };
---;
class TypedEmitter<T extends Record<string, any>> {
private handlers = new Map<keyof T, Set<(...args: any[]) => void>>();
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler);
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
this.handlers.get(event)?.forEach(h => h(payload));
}
---This emitter catches event name typos and payload type mismatches at compile time — emitter.emit('userLogin', { userId: 123 }) compiles, while emitter.emit('userLogin', { name: 'Alice' }) produces a type error.
Builder with Typed Steps (Phantom Types)
For builders where method call order matters, phantom types track the builder’s state at the type level:
type Pending = { validated: false; built: false };
type Validated = { validated: true; built: false };
type Completed = { validated: true; built: true };
class FormBuilder<T extends { validated: boolean; built: boolean }> {
private data: Record<string, unknown> = {};
addField(name: string, value: unknown): FormBuilder<T & { validated: false; built: false }> {
this.data[name] = value;
return this as any;
}
validate(): FormBuilder<T & { validated: true; built: false }> {
// run validation
return this as any;
}
build(this: FormBuilder<Completed>): Record<string, unknown> {
return this.data;
}
---
const form = new FormBuilder()
.addField('name', 'Alice')
.validate()
.build(); // OK — all steps completed
Calling build() without calling validate() first produces a type error, ensuring correct usage at compile time.
FAQ
Should I use design patterns in every project?
No. Patterns solve specific problems. Introducing a Singleton when a simple module-level variable works is over-engineering. Apply patterns when the problem matches the pattern’s intent.
Are TypeScript design patterns different from JavaScript patterns?
The intent is the same, but TypeScript’s type system enforces contracts at compile time. Access modifiers (private, protected), interfaces, and generics make patterns more robust and self-documenting.
What is the most useful pattern in TypeScript?
The discriminated union pattern (a behavioral/creational hybrid) is ubiquitous — it models API states, form validation, and finite state machines with exhaustive type-checking.
Does TypeScript’s structural typing change how patterns work?
Yes. Structural typing means you do not always need explicit interfaces — any object with the right shape satisfies the contract. This makes Adapter and Strategy patterns more flexible than in nominally typed languages.
How do I choose between a pattern and a simple function?
Start simple. Add structure when you need testability, interchangeability, or multiple implementations. Premature pattern application creates unnecessary complexity.