02 - Design Patterns (TypeScript) ​
Quick Reference (scan in 5 min) ​
| Pattern | Purpose | Key Idea | When to Use |
|---|---|---|---|
| Singleton | Exactly one instance | Private constructor + getInstance() | Logging, shared config, connection pools |
| Observer | React to state changes | Subject notifies a list of observers | Event systems, payment status updates, pub/sub |
| Chain of Responsibility | Pass request through a chain | Each handler decides: process or forward | Caching layers, middleware, validation pipelines |
| Strategy | Swap algorithms at runtime | Interface + interchangeable implementations | Payment methods, sorting, discount calculations |
| Factory | Create objects without exposing logic | Method returns instance based on input | Payment processors, notification channels |
| Builder | Construct complex objects step-by-step | Method chaining + final .build() | Transactions, API requests, query construction |
Key takeaways:
- Singleton and Observer are the most commonly asked in interviews
- Strategy and Factory show up in every fintech codebase (payment methods are the textbook use case)
- Chain of Responsibility is less common in interviews but very practical (caching, middleware)
- Builder shines whenever an object has 4+ optional fields
1. Singleton ​
What it is: A creational pattern that restricts a class to a single instance and provides a global access point to it.
When to use:
- Application-wide logging service
- Shared configuration / feature flags
- Database or Redis connection pool
- Analytics tracker (one instance collecting all events)
Implementation ​
class Logger {
private static instance: Logger;
private logs: string[] = [];
// Private constructor prevents `new Logger()` from outside
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message: string): void {
const timestamp = new Date().toISOString();
const entry = `[${timestamp}] ${message}`;
this.logs.push(entry);
console.log(entry);
}
warn(message: string): void {
this.log(`WARN: ${message}`);
}
error(message: string): void {
this.log(`ERROR: ${message}`);
}
getLogs(): readonly string[] {
return [...this.logs];
}
}
// Usage — always the same instance
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();
console.log(logger1 === logger2); // true
logger1.log('Payment initiated for INR 500');
logger2.log('Payment confirmed — txn_abc123');
console.log(logger1.getLogs().length); // 2 (same instance)Fintech example: Config service ​
interface AppConfig {
apiBaseUrl: string;
paymentGateway: 'razorpay' | 'paytm' | 'stripe';
maxRetries: number;
featureFlags: Record<string, boolean>;
}
class ConfigService {
private static instance: ConfigService;
private config: AppConfig | null = null;
private constructor() {}
static getInstance(): ConfigService {
if (!ConfigService.instance) {
ConfigService.instance = new ConfigService();
}
return ConfigService.instance;
}
async load(): Promise<void> {
const response = await fetch('/api/config');
this.config = await response.json();
}
get<K extends keyof AppConfig>(key: K): AppConfig[K] {
if (!this.config) throw new Error('Config not loaded. Call load() first.');
return this.config[key];
}
isFeatureEnabled(flag: string): boolean {
if (!this.config) return false;
return this.config.featureFlags[flag] ?? false;
}
}Trade-offs / gotchas ​
- Testing difficulty — Global state leaks between tests. Fix: add a
reset()method for test environments, or inject the singleton via DI instead of callinggetInstance()directly. - Hidden dependencies — Any module can grab the instance silently; hard to trace what depends on what. Prefer dependency injection when possible.
- Concurrency — In Node.js this is fine (single-threaded), but in multi-threaded environments the naive
if (!instance)check has a race condition. - Module-level singletons — In JS/TS, a module is only evaluated once. Exporting a plain object (
export const logger = new Logger()) achieves the same effect without the class ceremony.
2. Observer ​
What it is: A behavioral pattern where a subject maintains a list of observers and notifies them automatically of state changes.
When to use:
- Payment status updates (pending -> processing -> success/failure)
- Real-time notifications (new transactions, balance changes)
- Event-driven architectures / pub-sub within a module
- Decoupling producers from consumers
Full implementation with generics ​
// Generic type-safe EventEmitter
type Listener<T> = (data: T) => void;
class EventEmitter<EventMap extends Record<string, unknown>> {
private listeners = new Map<keyof EventMap, Set<Listener<any>>>();
subscribe<K extends keyof EventMap>(
event: K,
listener: Listener<EventMap[K]>
): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
// Return unsubscribe function (cleanup-friendly)
return () => {
this.listeners.get(event)?.delete(listener);
};
}
notify<K extends keyof EventMap>(event: K, data: EventMap[K]): void {
const eventListeners = this.listeners.get(event);
if (!eventListeners) return;
for (const listener of eventListeners) {
try {
listener(data);
} catch (error) {
console.error(`Error in listener for "${String(event)}":`, error);
}
}
}
listenerCount<K extends keyof EventMap>(event: K): number {
return this.listeners.get(event)?.size ?? 0;
}
removeAllListeners<K extends keyof EventMap>(event?: K): void {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
}
}Fintech example: Payment status notifications ​
// Define the event map — fully typed
interface PaymentEvents {
'payment:initiated': { txnId: string; amount: number; currency: string };
'payment:processing': { txnId: string; gateway: string };
'payment:success': { txnId: string; receipt: string; settledAt: Date };
'payment:failed': { txnId: string; reason: string; retryable: boolean };
'payment:refunded': { txnId: string; refundId: string; amount: number };
}
// Create a typed emitter
const paymentBus = new EventEmitter<PaymentEvents>();
// Observer 1: Update the UI
const unsubUI = paymentBus.subscribe('payment:success', (data) => {
// TypeScript knows: data is { txnId: string; receipt: string; settledAt: Date }
showSuccessScreen(data.txnId, data.receipt);
});
// Observer 2: Send analytics
paymentBus.subscribe('payment:success', (data) => {
analytics.track('payment_completed', {
transactionId: data.txnId,
settledAt: data.settledAt.toISOString(),
});
});
// Observer 3: Handle failures
paymentBus.subscribe('payment:failed', (data) => {
if (data.retryable) {
showRetryButton(data.txnId);
} else {
showContactSupport(data.reason);
}
});
// Emit from the payment service
function processPayment(txnId: string, amount: number) {
paymentBus.notify('payment:initiated', {
txnId,
amount,
currency: 'INR',
});
paymentBus.notify('payment:processing', {
txnId,
gateway: 'paytm',
});
// ... after gateway response
paymentBus.notify('payment:success', {
txnId,
receipt: 'RCP-2024-001',
settledAt: new Date(),
});
}
// Cleanup when component unmounts
unsubUI(); // removes the UI listener onlyTrade-offs / gotchas ​
- Memory leaks — Forgetting to unsubscribe is the #1 bug. Always return an unsubscribe function and call it on cleanup (e.g., in React's
useEffectreturn). - Ordering — Observers are notified in subscription order, but you should never depend on this.
- Error isolation — One observer throwing should not break others. The
try/catchinnotifyhandles this. - Debugging — Events are implicit; harder to trace than direct function calls. Use descriptive event names and log emits in development.
3. Chain of Responsibility ​
What it is: A behavioral pattern where a request is passed through a chain of handlers. Each handler either processes the request or forwards it to the next handler.
When to use:
- Caching layers (L1 memory -> L2 Redis -> L3 API)
- Middleware pipelines (auth -> rate limit -> validate -> handle)
- Validation chains (format check -> business rules -> fraud check)
- Logging / enrichment pipelines
Implementation: Caching chain ​
// Abstract handler
abstract class DataHandler<T> {
private next: DataHandler<T> | null = null;
setNext(handler: DataHandler<T>): DataHandler<T> {
this.next = handler;
return handler; // enables chaining: a.setNext(b).setNext(c)
}
async handle(key: string): Promise<T | null> {
const result = await this.process(key);
if (result !== null) {
return result;
}
if (this.next) {
return this.next.handle(key);
}
return null;
}
protected abstract process(key: string): Promise<T | null>;
}
// L1: In-memory cache
class MemoryCacheHandler<T> extends DataHandler<T> {
private cache = new Map<string, { data: T; expiry: number }>();
private ttlMs: number;
constructor(ttlMs: number = 30_000) {
super();
this.ttlMs = ttlMs;
}
protected async process(key: string): Promise<T | null> {
const entry = this.cache.get(key);
if (entry && Date.now() < entry.expiry) {
console.log(`[L1 Memory] HIT for "${key}"`);
return entry.data;
}
console.log(`[L1 Memory] MISS for "${key}"`);
return null;
}
set(key: string, data: T): void {
this.cache.set(key, { data, expiry: Date.now() + this.ttlMs });
}
}
// L2: Redis cache (simulated)
class RedisCacheHandler<T> extends DataHandler<T> {
private store = new Map<string, string>(); // simulates Redis
protected async process(key: string): Promise<T | null> {
const cached = this.store.get(key);
if (cached) {
console.log(`[L2 Redis] HIT for "${key}"`);
return JSON.parse(cached) as T;
}
console.log(`[L2 Redis] MISS for "${key}"`);
return null;
}
set(key: string, data: T): void {
this.store.set(key, JSON.stringify(data));
}
}
// L3: API (the final source of truth)
class ApiHandler<T> extends DataHandler<T> {
private fetchFn: (key: string) => Promise<T>;
constructor(fetchFn: (key: string) => Promise<T>) {
super();
this.fetchFn = fetchFn;
}
protected async process(key: string): Promise<T | null> {
console.log(`[L3 API] Fetching "${key}" from server`);
try {
return await this.fetchFn(key);
} catch (error) {
console.error(`[L3 API] Failed to fetch "${key}"`, error);
return null;
}
}
}DataService wiring it together ​
interface Transaction {
id: string;
amount: number;
currency: string;
status: 'pending' | 'completed' | 'failed';
}
class TransactionDataService {
private memoryCache: MemoryCacheHandler<Transaction>;
private redisCache: RedisCacheHandler<Transaction>;
private apiHandler: ApiHandler<Transaction>;
private chain: DataHandler<Transaction>;
constructor() {
this.memoryCache = new MemoryCacheHandler<Transaction>(30_000); // 30s
this.redisCache = new RedisCacheHandler<Transaction>();
this.apiHandler = new ApiHandler<Transaction>(async (key) => {
const response = await fetch(`/api/transactions/${key}`);
return response.json();
});
// Build the chain: Memory -> Redis -> API
this.memoryCache.setNext(this.redisCache).setNext(this.apiHandler);
this.chain = this.memoryCache;
}
async getTransaction(id: string): Promise<Transaction | null> {
const result = await this.chain.handle(id);
// Backfill caches on API hit
if (result) {
this.memoryCache.set(id, result);
this.redisCache.set(id, result);
}
return result;
}
}
// Usage
const service = new TransactionDataService();
// First call: L1 MISS -> L2 MISS -> L3 API fetch
await service.getTransaction('txn_001');
// Second call: L1 HIT (served from memory)
await service.getTransaction('txn_001');Trade-offs / gotchas ​
- Order matters — The chain runs fastest-first (memory before Redis before API). Reordering breaks performance assumptions.
- Silent failures — If no handler processes the request and the chain ends, you get
null. Make sure the last handler always responds or throw explicitly. - Debugging — Requests traverse multiple handlers. Add logging at each level to trace which handler served the response.
- Backfill logic — The chain itself does not backfill upper caches. You need to handle that in the orchestrator (as shown above in
getTransaction).
4. Strategy ​
What it is: A behavioral pattern that defines a family of algorithms, encapsulates each one, and makes them interchangeable at runtime.
When to use:
- Multiple payment methods (card, UPI, wallet) with different processing logic
- Different discount calculation strategies (percentage, flat, tiered)
- Sorting algorithms swapped based on data size
- Validation strategies (strict for production, lenient for sandbox)
Implementation ​
// The strategy interface — the contract all strategies must follow
interface PaymentStrategy {
readonly name: string;
validate(amount: number): boolean;
process(amount: number, currency: string): Promise<PaymentResult>;
calculateFees(amount: number): number;
}
interface PaymentResult {
success: boolean;
transactionId: string;
method: string;
netAmount: number;
}
// Strategy 1: Credit Card
class CreditCardStrategy implements PaymentStrategy {
readonly name = 'credit_card';
validate(amount: number): boolean {
return amount > 0 && amount <= 500_000; // max 5 lakh per txn
}
async process(amount: number, currency: string): Promise<PaymentResult> {
const fees = this.calculateFees(amount);
console.log(`Processing card payment: ${currency} ${amount} (fees: ${fees})`);
// ... card gateway API call
return {
success: true,
transactionId: `CC-${Date.now()}`,
method: this.name,
netAmount: amount - fees,
};
}
calculateFees(amount: number): number {
return amount * 0.02; // 2% processing fee
}
}
// Strategy 2: UPI
class UPIStrategy implements PaymentStrategy {
readonly name = 'upi';
validate(amount: number): boolean {
return amount > 0 && amount <= 100_000; // UPI limit: 1 lakh
}
async process(amount: number, currency: string): Promise<PaymentResult> {
const fees = this.calculateFees(amount);
console.log(`Processing UPI payment: ${currency} ${amount} (fees: ${fees})`);
// ... UPI gateway API call
return {
success: true,
transactionId: `UPI-${Date.now()}`,
method: this.name,
netAmount: amount - fees,
};
}
calculateFees(amount: number): number {
return 0; // UPI is zero-fee in India
}
}
// Strategy 3: Wallet
class WalletStrategy implements PaymentStrategy {
readonly name = 'wallet';
private balance: number;
constructor(balance: number) {
this.balance = balance;
}
validate(amount: number): boolean {
return amount > 0 && amount <= this.balance;
}
async process(amount: number, currency: string): Promise<PaymentResult> {
if (!this.validate(amount)) {
return {
success: false,
transactionId: '',
method: this.name,
netAmount: 0,
};
}
this.balance -= amount;
const fees = this.calculateFees(amount);
console.log(`Processing wallet payment: ${currency} ${amount} (fees: ${fees})`);
return {
success: true,
transactionId: `WAL-${Date.now()}`,
method: this.name,
netAmount: amount - fees,
};
}
calculateFees(amount: number): number {
return amount * 0.01; // 1% wallet fee
}
}Context class ​
class PaymentProcessor {
private strategy: PaymentStrategy;
constructor(strategy: PaymentStrategy) {
this.strategy = strategy;
}
// Swap strategy at runtime
setStrategy(strategy: PaymentStrategy): void {
console.log(`Switching payment method: ${this.strategy.name} -> ${strategy.name}`);
this.strategy = strategy;
}
async pay(amount: number, currency: string = 'INR'): Promise<PaymentResult> {
if (!this.strategy.validate(amount)) {
throw new Error(
`Validation failed for ${this.strategy.name}: amount ${amount} is not valid`
);
}
const fees = this.strategy.calculateFees(amount);
console.log(`Fees for ${this.strategy.name}: ${currency} ${fees}`);
return this.strategy.process(amount, currency);
}
}
// Usage
const processor = new PaymentProcessor(new CreditCardStrategy());
await processor.pay(1000, 'INR'); // CC-1708000000, fees: 20
// User switches to UPI at runtime
processor.setStrategy(new UPIStrategy());
await processor.pay(1000, 'INR'); // UPI-1708000001, fees: 0
// User switches to wallet
processor.setStrategy(new WalletStrategy(5000));
await processor.pay(1000, 'INR'); // WAL-1708000002, fees: 10Trade-offs / gotchas ​
- When there are only 2 strategies — An
if/elseis simpler. Strategy shines when you have 3+ options or expect growth. - Strategy selection logic — Someone still has to pick the right strategy. Often combined with a Factory (see next section).
- Shared state — Strategies should be stateless if possible. The
WalletStrategyabove holds balance, which makes it harder to share across contexts. - Over-engineering — If the algorithms never change and there are only a few, a simple map of functions works fine.
5. Factory ​
What it is: A creational pattern that provides an interface for creating objects without specifying their exact class. The factory method decides which class to instantiate based on input.
When to use:
- Creating payment processors based on selected method
- Instantiating notification channels (SMS, email, push)
- Building different report formats (PDF, CSV, Excel)
- Any place where object creation logic is complex or conditional
Simple Factory ​
// Reusing the PaymentStrategy interface from the Strategy section
class PaymentStrategyFactory {
private static strategies: Record<string, () => PaymentStrategy> = {
credit_card: () => new CreditCardStrategy(),
upi: () => new UPIStrategy(),
wallet: () => new WalletStrategy(0), // balance injected separately
};
static create(method: string): PaymentStrategy {
const creator = this.strategies[method];
if (!creator) {
throw new Error(`Unknown payment method: "${method}"`);
}
return creator();
}
// Register new strategies without modifying existing code (Open/Closed)
static register(method: string, creator: () => PaymentStrategy): void {
this.strategies[method] = creator;
}
}
// Usage
const strategy = PaymentStrategyFactory.create('upi');
const processor = new PaymentProcessor(strategy);
await processor.pay(500, 'INR');
// Register a new method at runtime
PaymentStrategyFactory.register('bnpl', () => new BuyNowPayLaterStrategy());
const bnpl = PaymentStrategyFactory.create('bnpl');Abstract Factory: Creating families of related objects ​
// When you need to create groups of related objects that belong together
interface UIComponentFactory {
createButton(label: string): ButtonComponent;
createInput(placeholder: string): InputComponent;
createLoader(): LoaderComponent;
}
interface ButtonComponent {
render(): string;
}
interface InputComponent {
render(): string;
}
interface LoaderComponent {
render(): string;
}
// Family 1: Paytm-branded components
class PaytmUIFactory implements UIComponentFactory {
createButton(label: string): ButtonComponent {
return {
render: () => `<button class="paytm-btn paytm-blue">${label}</button>`,
};
}
createInput(placeholder: string): InputComponent {
return {
render: () =>
`<input class="paytm-input" placeholder="${placeholder}" />`,
};
}
createLoader(): LoaderComponent {
return {
render: () => `<div class="paytm-spinner"></div>`,
};
}
}
// Family 2: Merchant-branded components
class MerchantUIFactory implements UIComponentFactory {
constructor(private brandColor: string) {}
createButton(label: string): ButtonComponent {
return {
render: () =>
`<button style="background:${this.brandColor}">${label}</button>`,
};
}
createInput(placeholder: string): InputComponent {
return {
render: () =>
`<input class="merchant-input" placeholder="${placeholder}" />`,
};
}
createLoader(): LoaderComponent {
return {
render: () =>
`<div class="merchant-spinner" style="color:${this.brandColor}"></div>`,
};
}
}
// Client code doesn't know which factory it's using
function renderCheckoutPage(factory: UIComponentFactory) {
const payButton = factory.createButton('Pay Now');
const amountInput = factory.createInput('Enter amount');
const loader = factory.createLoader();
return `
${amountInput.render()}
${payButton.render()}
${loader.render()}
`;
}
// Switch entire UI family by changing the factory
const paytmUI = renderCheckoutPage(new PaytmUIFactory());
const merchantUI = renderCheckoutPage(new MerchantUIFactory('#FF6600'));Trade-offs / gotchas ​
- Simple Factory vs Factory Method vs Abstract Factory — Know the difference. Simple Factory is a static method. Factory Method uses inheritance. Abstract Factory creates families.
- Complexity — For 2-3 types, a factory is fine. For 10+, consider a registry pattern (as shown in the
registermethod above). - Return type — The factory should return an interface, not a concrete class. This keeps calling code decoupled.
- Dependency injection overlap — In modern TS/JS, DI containers (like tsyringe or InversifyJS) do what factories do but with more features. Factories are still useful for simpler cases.
6. Builder ​
What it is: A creational pattern that constructs a complex object step-by-step. The same construction process can create different representations.
When to use:
- Transaction objects with many optional fields
- Complex API request construction
- Building query strings or filter objects
- Configuration objects with validation
Implementation: Transaction Builder ​
interface Transaction {
id: string;
amount: number;
currency: string;
recipient: string;
sender: string;
description: string;
metadata: Record<string, string>;
scheduledAt: Date | null;
idempotencyKey: string;
priority: 'low' | 'normal' | 'high';
}
class TransactionBuilder {
private transaction: Partial<Transaction> = {};
constructor() {
// Set sensible defaults
this.transaction.id = crypto.randomUUID();
this.transaction.currency = 'INR';
this.transaction.metadata = {};
this.transaction.scheduledAt = null;
this.transaction.priority = 'normal';
this.transaction.idempotencyKey = crypto.randomUUID();
}
setAmount(amount: number): this {
if (amount <= 0) throw new Error('Amount must be positive');
if (amount > 10_000_000) throw new Error('Amount exceeds maximum limit');
this.transaction.amount = amount;
return this;
}
setCurrency(currency: string): this {
const allowed = ['INR', 'USD', 'EUR', 'GBP'];
if (!allowed.includes(currency)) {
throw new Error(`Unsupported currency: ${currency}`);
}
this.transaction.currency = currency;
return this;
}
setRecipient(recipient: string): this {
if (!recipient.trim()) throw new Error('Recipient cannot be empty');
this.transaction.recipient = recipient;
return this;
}
setSender(sender: string): this {
if (!sender.trim()) throw new Error('Sender cannot be empty');
this.transaction.sender = sender;
return this;
}
setDescription(description: string): this {
this.transaction.description = description;
return this;
}
addMetadata(key: string, value: string): this {
this.transaction.metadata![key] = value;
return this;
}
scheduleAt(date: Date): this {
if (date <= new Date()) throw new Error('Scheduled date must be in the future');
this.transaction.scheduledAt = date;
return this;
}
setPriority(priority: 'low' | 'normal' | 'high'): this {
this.transaction.priority = priority;
return this;
}
setIdempotencyKey(key: string): this {
this.transaction.idempotencyKey = key;
return this;
}
build(): Transaction {
// Validate all required fields are present
const required: (keyof Transaction)[] = ['amount', 'recipient', 'sender'];
for (const field of required) {
if (this.transaction[field] === undefined) {
throw new Error(`Missing required field: "${field}"`);
}
}
return { ...this.transaction } as Transaction;
}
}Usage with method chaining ​
// Clean, readable construction
const txn = new TransactionBuilder()
.setAmount(25_000)
.setCurrency('INR')
.setRecipient('merchant_paytm_001')
.setSender('user_aayush_42')
.setDescription('Monthly subscription payment')
.addMetadata('plan', 'premium')
.addMetadata('billingCycle', '2024-02')
.setPriority('high')
.build();
console.log(txn);
// {
// id: 'a1b2c3d4-...',
// amount: 25000,
// currency: 'INR',
// recipient: 'merchant_paytm_001',
// sender: 'user_aayush_42',
// description: 'Monthly subscription payment',
// metadata: { plan: 'premium', billingCycle: '2024-02' },
// scheduledAt: null,
// idempotencyKey: 'e5f6g7h8-...',
// priority: 'high'
// }
// Scheduled payment
const scheduledTxn = new TransactionBuilder()
.setAmount(10_000)
.setRecipient('merchant_electricity_board')
.setSender('user_aayush_42')
.setDescription('Electricity bill')
.scheduleAt(new Date('2024-03-01'))
.build();
// Minimal required fields only
const simpleTxn = new TransactionBuilder()
.setAmount(500)
.setRecipient('friend_rahul')
.setSender('user_aayush_42')
.build();Director pattern (optional, for predefined configurations) ​
class TransactionDirector {
static createP2PTransfer(
sender: string,
recipient: string,
amount: number
): Transaction {
return new TransactionBuilder()
.setAmount(amount)
.setSender(sender)
.setRecipient(recipient)
.setDescription('P2P Transfer')
.setPriority('normal')
.build();
}
static createMerchantPayment(
sender: string,
merchantId: string,
amount: number,
orderId: string
): Transaction {
return new TransactionBuilder()
.setAmount(amount)
.setSender(sender)
.setRecipient(merchantId)
.setDescription(`Payment for order ${orderId}`)
.addMetadata('orderId', orderId)
.addMetadata('type', 'merchant_payment')
.setPriority('high')
.build();
}
static createScheduledBillPayment(
sender: string,
biller: string,
amount: number,
dueDate: Date
): Transaction {
return new TransactionBuilder()
.setAmount(amount)
.setSender(sender)
.setRecipient(biller)
.setDescription('Scheduled bill payment')
.scheduleAt(dueDate)
.addMetadata('type', 'bill_payment')
.setPriority('low')
.build();
}
}
// Usage — clean one-liners for common cases
const p2p = TransactionDirector.createP2PTransfer('user_a', 'user_b', 500);
const merchant = TransactionDirector.createMerchantPayment(
'user_a', 'shop_xyz', 1299, 'ORD-123'
);Trade-offs / gotchas ​
- When to use — If the constructor has 4+ parameters or the object has many optional fields, use a Builder. For simple objects, just use an object literal.
- Immutability — The built object should be frozen or at minimum defensively copied (
{ ...this.transaction }) so the builder cannot mutate it afterbuild(). - Reuse — A builder instance should not be reused after
build(). Either create a new builder each time or add areset()method. - Validation timing — Validate at
build()time (not in each setter) if you want to allow partial construction. Validate in setters (as shown above) if you want early failure. - Builder vs object literal — In TypeScript,
Partial<T>+ a validate function is often simpler. Builders add value when there is construction logic (defaults, derived fields, complex validation).