Siddhant Deval
Siddhant Deval
system design17 min read

Creational Design Patterns: Factories, Builders & Const Construction

Object creation in enterprise TypeScript must balance complex runtime construction logic with compile-time type safety. This article covers Factory Method, Abstract Factory, ESM vs. class Singletons, and a type-safe Builder pattern using TypeScript 5.0 `<const T>` generics and phantom types to prevent incomplete construction at compile time.

Creational Design Patterns: Factories, Builders & Const Construction

In TypeScript 5+, OOP is an architectural contract, not an inheritance tree: enforce domain invariants at compile time, encapsulate mutation strictly within aggregates, and invert dependencies so high-level business policy never couples to execution details. The creational patterns in this article are the mechanism for enforcing that contract at the moment of object birth — ensuring that no partially-constructed, invariant-violating object can exist in the system.

Classical GoF creational patterns were designed for nominal, statically-typed OOP languages. TypeScript 5+ offers something those languages could not: phantom types and <const T> generics that make illegal construction a compile-time impossibility rather than a runtime exception.


1. The Anti-Pattern Graveyard: The Telescoping Constructor

Here is the object construction pattern that breaks silently under parameter re-ordering:

TYPESCRIPT
// ❌ Anti-Pattern: Telescoping constructor — parameter order is invisible at call sites

class Order {
  constructor(
    orderId: string,
    customerId: string,
    shippingAddressId: string,
    billingAddressId: string,
    currency: string,
    isPriority: boolean,
    discountCode: string | null,
    taxRate: number,
  ) { /* ... */ }
}

// Call site — what does each positional argument mean?
const order = new Order(
  'ord_001',
  'cus_abc',
  'addr_shipping',
  'addr_billing',  // ← Did you mean shippingAddressId or billingAddressId?
  'USD',
  true,
  null,
  0.08,            // ← 8% tax rate — or 8%? Easy to confuse with 0.8
);

Swapping shippingAddressId and billingAddressId produces a runtime bug. Both are string. The type system cannot distinguish them without branding (Part 2), and the call site provides no visual anchors. Adding an optional parameter requires updating every call site.


2. Factory Method: Encapsulating Construction Branching

2.1 When to Use a Factory

Use a Factory Method when:

  • Object creation involves runtime branching based on environment, configuration, or user input
  • The caller should not know which concrete class it receives — only the interface
  • Future variants may be added without changing the call site
TYPESCRIPT
// ✅ Factory Method — runtime selection of concrete gateway

interface IPaymentGateway {
  charge(amount: number, currency: string): Promise<PaymentResult>;
}

type PaymentResult = { status: 'OK'; transactionId: string } | { status: 'FAILED'; reason: string };
type GatewayProvider = 'stripe' | 'paypal' | 'adyen' | 'mock';

// Factory function — no class required for a factory
function createPaymentGateway(provider: GatewayProvider): IPaymentGateway {
  switch (provider) {
    case 'stripe': return new StripeGateway(process.env.STRIPE_KEY!);
    case 'paypal': return new PayPalGateway(process.env.PAYPAL_CLIENT_ID!);
    case 'adyen':  return new AdyenGateway(process.env.ADYEN_API_KEY!);
    case 'mock':   return new MockPaymentGateway();
    default:       throw new Error(`Unknown provider: ${provider}`);
  }
}

// Caller only knows about IPaymentGateway — never sees StripeGateway
const gateway = createPaymentGateway(process.env.PAYMENT_PROVIDER as GatewayProvider);

2.2 Abstract Factory: Coordinating Families of Products

An Abstract Factory produces multiple related objects that must be used together consistently. In the fintech domain, this is the SandboxLedgerFactory vs ProductionLedgerFactory pattern — every object created by a sandbox factory is a sandbox object:

TYPESCRIPT
interface ILedgerFactory {
  createGateway(): IPaymentGateway;
  createRepository(): IOrderRepository;
  createAuditLogger(): IAuditLogger;
}

class SandboxLedgerFactory implements ILedgerFactory {
  createGateway(): IPaymentGateway { return new MockPaymentGateway(); }
  createRepository(): IOrderRepository { return new InMemoryOrderRepository(); }
  createAuditLogger(): IAuditLogger { return new ConsoleAuditLogger(); }
}

class ProductionLedgerFactory implements ILedgerFactory {
  createGateway(): IPaymentGateway { return new StripeGateway(process.env.STRIPE_KEY!); }
  createRepository(): IOrderRepository { return new PostgresOrderRepository(process.env.DB_URL!); }
  createAuditLogger(): IAuditLogger { return new CloudwatchAuditLogger(); }
}

// Entire environment is switched by passing a different factory
function bootstrapApplication(factory: ILedgerFactory) {
  const gateway = factory.createGateway();
  const repo = factory.createRepository();
  const logger = factory.createAuditLogger();
  return new PaymentOrchestrator(gateway, repo, logger);
}

const orchestrator = bootstrapApplication(
  process.env.NODE_ENV === 'test'
    ? new SandboxLedgerFactory()
    : new ProductionLedgerFactory(),
);

3. Singletons: ESM Module vs. Class Static

3.1 Why Class Singletons Are Problematic

TYPESCRIPT
// ❌ Class singleton — global mutable state infects tests

class ConfigService {
  private static instance: ConfigService | null = null;
  private config: Record<string, string> = {};

  private constructor() {
    this.config = { apiKey: process.env.API_KEY ?? '' };
  }

  static getInstance(): ConfigService {
    if (!ConfigService.instance) {
      ConfigService.instance = new ConfigService();
    }
    return ConfigService.instance;
  }

  get(key: string): string { return this.config[key]; }
}

// In test A: ConfigService.getInstance() returns instance with test env
// In test B: ConfigService.getInstance() returns the SAME instance from test A
// Tests are order-dependent — a CI nightmare

3.2 ESM Module Singleton

TYPESCRIPT
// ✅ ESM module singleton — lazy, isolated per module resolution

// src/infrastructure/config.ts
class ConfigService {
  private readonly config: Record<string, string>;

  constructor() {
    this.config = { apiKey: process.env.API_KEY ?? '' };
  }

  get(key: string): string { return this.config[key]; }
}

// The module-level export IS the singleton — one instance per module graph
export const configService = new ConfigService();

In tests, mock the module: jest.mock('./config', () => ({ configService: { get: jest.fn() } })). Each test file gets a fresh module scope — no shared state between test files.


4. Type-Safe Builder with Phantom Types

The Builder pattern solves telescoping constructors by assembling objects step-by-step via method chaining. TypeScript 5+ takes this further: phantom type parameters track which fields have been set at the type level, making incomplete construction a compile-time error.

4.1 The Phantom Type State Machine

TYPESCRIPT
// Phantom type markers — exist only in the type system, never at runtime
declare const __set: unique symbol;
type Set   = { readonly [__set]: true };
type Unset = { readonly [__set]: false };

// OrderBuilder with phantom parameters tracking which fields are set
class OrderBuilder<
  TCustomer extends Set | Unset = Unset,
  TItems    extends Set | Unset = Unset,
  TCurrency extends Set | Unset = Unset,
> {
  private readonly #data: {
    customerId?: string;
    items?: Array<{ productId: string; quantity: number }>;
    currency?: string;
    isPriority: boolean;
    discountCode: string | null;
  } = { isPriority: false, discountCode: null };

  // Each setter returns a new builder with an updated phantom type
  withCustomer(customerId: string): OrderBuilder<Set, TItems, TCurrency> {
    this.#data.customerId = customerId;
    return this as unknown as OrderBuilder<Set, TItems, TCurrency>;
  }

  withItems(items: Array<{ productId: string; quantity: number }>): OrderBuilder<TCustomer, Set, TCurrency> {
    this.#data.items = items;
    return this as unknown as OrderBuilder<TCustomer, Set, TCurrency>;
  }

  withCurrency(currency: string): OrderBuilder<TCustomer, TItems, Set> {
    this.#data.currency = currency;
    return this as unknown as OrderBuilder<TCustomer, TItems, Set>;
  }

  withPriority(priority: boolean): this {
    this.#data.isPriority = priority;
    return this;
  }

  withDiscount(code: string): this {
    this.#data.discountCode = code;
    return this;
  }

  // .build() is ONLY available when all three phantom types are Set
  build(
    this: OrderBuilder<Set, Set, Set>, // ← Constraint on `this`
  ): Order {
    return {
      customerId: this.#data.customerId!,
      items: this.#data.items!,
      currency: this.#data.currency!,
      isPriority: this.#data.isPriority,
      discountCode: this.#data.discountCode,
    };
  }
}

type Order = {
  customerId: string;
  items: Array<{ productId: string; quantity: number }>;
  currency: string;
  isPriority: boolean;
  discountCode: string | null;
};

// ✅ Complete construction — all required fields set
const order = new OrderBuilder()
  .withCustomer('cus_abc123')
  .withItems([{ productId: 'prd_001', quantity: 2 }])
  .withCurrency('USD')
  .withPriority(true)
  .build(); // ← Allowed: all phantom types are Set

// ❌ Incomplete construction — compile error
const incomplete = new OrderBuilder()
  .withCustomer('cus_abc123')
  .withCurrency('USD')
  .build();
// Error: Argument of type 'OrderBuilder<Set, Unset, Set>' is not assignable to
// parameter of type 'OrderBuilder<Set, Set, Set>'
// → Missing: .withItems(...)
Crucial Requirement

The phantom type approach moves the "required field" check from runtime (if (!this.items) throw) to compile time. No test can catch a missing .withItems() call faster than the TypeScript compiler.


5. TypeScript 5.0 <const T> Generic Parameters

Before TS 5.0, builders and factories that accepted configuration objects had to ask callers to annotate with as const to preserve literal types. TS 5.0 eliminates this requirement:

TYPESCRIPT
// Without <const T>: caller must add 'as const' to preserve literal types
function createConfig<T extends Record<string, string>>(config: T): T {
  return config;
}

const cfg1 = createConfig({ provider: 'stripe', env: 'production' });
// cfg1.provider: string — widened, not 'stripe'

const cfg2 = createConfig({ provider: 'stripe', env: 'production' } as const);
// cfg2.provider: 'stripe' — preserved

// ✅ With <const T> (TS 5.0+): no 'as const' required
function createConfig<const T extends Record<string, string>>(config: T): T {
  return config;
}

const cfg3 = createConfig({ provider: 'stripe', env: 'production' });
// cfg3.provider: 'stripe' — automatically preserved as literal type

This pattern is especially useful in strategy registries (Part 6), plugin systems, and route configuration builders where preserving literal types enables downstream type inference.


6. Interview Application: Factory vs Builder Decision

In an LLD interview with a 60-minute limit, a common mistake is spending 15 minutes implementing a full Builder when a simple factory function would have been sufficient. Here is the heuristic:

Construction Need Use
Runtime type selection (env/config-based) Factory function / Factory Method
Multiple coordinated objects Abstract Factory
Single instance per process ESM module export
Complex object with many optional fields Builder Pattern
Builder where field combinations must be validated at compile time Builder + Phantom Types
Object cloning with structural sharing Spread / Object.assign
TYPESCRIPT
// ✅ 2-minute factory function for "support Stripe and Adyen" interview prompt
function createGateway(provider: 'stripe' | 'adyen'): IPaymentGateway {
  return provider === 'stripe' ? new StripeGateway() : new AdyenGateway();
}

// Save the Builder + Phantom Types for when the interviewer specifically asks
// "how do you prevent incomplete object construction at compile time?"

Summary

Pattern Use When TS 5+ Feature
Factory Function Runtime type selection, simple branching Type-narrowing switch with literal union return
Abstract Factory Coordinated families of related objects Interface-typed factory methods
ESM Singleton One shared stateless instance per module graph Module-level export const
Builder + Phantom Types Complex objects with required field validation this: Builder<Set, Set, Set> constraint
<const T> Generics Preserve literal types without caller as const function f<const T>()
Prototype Cloning Cheap copies of complex objects Spread / structural sharing

What's Next

In Part 9, we move to structural patterns — the connective tissue between incompatible interfaces, resource-heavy objects, and hierarchical domain trees. Part 9: Structural Design Patterns: Adapters, Proxies, Facades & Trees covers the Adapter as an anti-corruption layer against vendor SDK churn, native ECMAScript Proxy for transparent interception, and the Composite pattern for recursive fee calculation trees.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#TypeScript#Design Patterns#Factory Pattern#Builder Pattern#Phantom Types#LLD Interview
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.