Skip to content

The Evolution of Creational Patterns in Modern TypeScript

Exploring how Singleton, Factory, Builder, and Prototype patterns have evolved in TypeScript. Learn when ES modules replace singletons, when factory functions beat classes, and how TypeScript's type system changes the game.

The Gang of Four's design patterns book came out in 1994, targeting C++ and Smalltalk developers facing object instantiation challenges. Over 30 years later, TypeScript gives us optional parameters, default values, destructuring, ES modules, and a sophisticated type system. The creational patterns haven't disappeared - they've evolved.

This post examines how Singleton, Factory, Builder, and Prototype patterns manifest in modern TypeScript codebases, when they still add value, and when language features have made them unnecessary.

The Singleton Pattern: From Anti-Pattern to Context-Dependent Tool

The Singleton pattern ensures a class has only one instance with global access. In 1994, this solved real problems. In 2025 TypeScript, it's often an anti-pattern - but not always.

The Classic Problem

Here's the textbook singleton everyone learns:

typescript
class DatabaseConnection {  private static instance: DatabaseConnection;  private constructor() {    // Private constructor prevents direct instantiation  }
  static getInstance(): DatabaseConnection {    if (!DatabaseConnection.instance) {      DatabaseConnection.instance = new DatabaseConnection();    }    return DatabaseConnection.instance;  }
  query(sql: string): Promise<any> {    // Database operations  }}
// Usageconst db = DatabaseConnection.getInstance();await db.query('SELECT * FROM users');

This works, but creates testing nightmares. You can't easily mock the instance, can't inject different configurations, and each test shares global state.

Modern Alternative: ES Modules as Natural Singletons

ES modules are cached after first import. Importing the same module multiple times returns the same instance:

typescript
// db-connection.tsclass DatabaseConnection {  constructor(private config: DatabaseConfig) {    // Setup logic  }
  query(sql: string): Promise<any> {    // Database operations  }}
// Single instance exportedexport const db = new DatabaseConnection({  host: process.env.DB_HOST,  port: parseInt(process.env.DB_PORT),});
// other-file.tsimport { db } from './db-connection';await db.query('SELECT * FROM users');
// another-file.tsimport { db } from './db-connection'; // Same instance

The module system provides singleton behavior without the pattern's complexity. The instance is created once, shared across imports, and testable through module mocking.

Dependency Injection Containers

For complex applications, dependency injection containers manage object lifecycles:

typescript
import { injectable, inject, container } from 'tsyringe';
@injectable()class DatabaseConnection {  constructor(    @inject('DatabaseConfig') private config: DatabaseConfig  ) {    // Setup logic  }}
// Register as singletoncontainer.registerSingleton(DatabaseConnection);
// Usage in any class@injectable()class UserRepository {  constructor(private db: DatabaseConnection) {    // Automatically receives singleton instance  }}

DI containers give you singleton semantics with dependency injection benefits. Testing becomes straightforward - swap implementations in the container.

When Singleton Still Makes Sense

Some scenarios genuinely benefit from explicit singleton pattern:

Logger with configured transports:

typescript
class Logger {  private static instance: Logger;  private transports: Transport[] = [];
  private constructor() {}
  static getInstance(): Logger {    if (!Logger.instance) {      Logger.instance = new Logger();    }    return Logger.instance;  }
  addTransport(transport: Transport): void {    this.transports.push(transport);  }
  log(level: string, message: string): void {    this.transports.forEach(t => t.write(level, message));  }}
// Initialize once at app startupconst logger = Logger.getInstance();logger.addTransport(new ConsoleTransport());logger.addTransport(new FileTransport('/var/log/app.log'));
// Use everywhere without configurationlogger.log('info', 'Application started');

Feature flag manager:

typescript
class FeatureFlags {  private static instance: FeatureFlags;  private flags = new Map<string, boolean>();
  private constructor() {}
  static getInstance(): FeatureFlags {    if (!FeatureFlags.instance) {      FeatureFlags.instance = new FeatureFlags();    }    return FeatureFlags.instance;  }
  async initialize(): Promise<void> {    // Load flags from remote config    const response = await fetch('/api/feature-flags');    const data = await response.json();    data.forEach((flag: any) => this.flags.set(flag.name, flag.enabled));  }
  isEnabled(flagName: string): boolean {    return this.flags.get(flagName) ?? false;  }}
// Initialize at startupawait FeatureFlags.getInstance().initialize();
// Check anywhereif (FeatureFlags.getInstance().isEnabled('new-dashboard')) {  // Show new dashboard}

These examples work because they represent truly application-wide concerns requiring centralized configuration.

Anti-Pattern Examples

Don't use singleton for dependencies that should be injected:

typescript
// DON'T: Hard to test, can't swap implementationsclass ApiClient {  private static instance: ApiClient;  private baseUrl = 'https://api.prod.com';
  private constructor() {}
  static getInstance(): ApiClient {    if (!ApiClient.instance) {      ApiClient.instance = new ApiClient();    }    return ApiClient.instance;  }}
// DO: Accept dependencies as constructor parametersclass ApiClient {  constructor(    private config: ApiConfig,    private httpClient: HttpClient  ) {}
  async get(endpoint: string): Promise<any> {    return this.httpClient.get(`${this.config.baseUrl}${endpoint}`);  }}
// Productionconst apiClient = new ApiClient(  { baseUrl: 'https://api.prod.com' },  new HttpClient());
// Testingconst apiClient = new ApiClient(  { baseUrl: 'http://localhost:3000' },  new MockHttpClient());

The Factory Pattern: Functions vs Classes

Factory pattern encapsulates object creation logic. In TypeScript, you have choices: factory functions, factory classes, or discriminated unions with functions.

When Factory Functions Suffice

Simple creation logic doesn't need classes:

typescript
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
function createLogger(level: LogLevel): Logger {  switch (level) {    case 'debug':      return new DebugLogger();    case 'info':      return new InfoLogger();    case 'warn':      return new WarnLogger();    case 'error':      return new ErrorLogger();  }}
// Type-safe with exhaustive checkingconst logger = createLogger('debug');

TypeScript's exhaustive checking ensures you handle all cases. If you add a log level, the compiler catches missing implementations.

When Factory Classes Add Value

Factory classes make sense when creation logic requires shared configuration:

typescript
import { Function, Runtime, Duration, ILayerVersion } from 'aws-cdk-lib/aws-lambda';import { IVpc, ISecurityGroup, SubnetType } from 'aws-cdk-lib/aws-ec2';import { Construct } from 'constructs';
class LambdaFunctionFactory {  constructor(    private vpc: IVpc,    private layers: ILayerVersion[],    private securityGroup: ISecurityGroup,    private scope: Construct  ) {}
  createApiHandler(config: ApiHandlerConfig): Function {    return new Function(this.scope, config.id, {      vpc: this.vpc,      vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },      securityGroups: [this.securityGroup],      layers: this.layers,      runtime: Runtime.NODEJS_20_X,      timeout: Duration.seconds(30),      memorySize: 1024,      ...config,    });  }
  createWorkerHandler(config: WorkerConfig): Function {    return new Function(this.scope, config.id, {      vpc: this.vpc,      vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },      securityGroups: [this.securityGroup],      layers: this.layers,      runtime: Runtime.NODEJS_20_X,      timeout: Duration.minutes(15),      memorySize: 2048, // Workers need more memory      ...config,    });  }
  createScheduledHandler(config: ScheduledConfig): Function {    return new Function(this.scope, config.id, {      vpc: this.vpc,      vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },      securityGroups: [this.securityGroup],      layers: this.layers,      runtime: Runtime.NODEJS_20_X,      timeout: Duration.minutes(5),      memorySize: 512, // Scheduled tasks typically lighter      ...config,    });  }}
// Usage in CDK stackconst factory = new LambdaFunctionFactory(  vpc,  [commonLayer, vendorLayer],  lambdaSecurityGroup,  this);
const getUserHandler = factory.createApiHandler({  id: 'GetUserHandler',  handler: 'dist/handlers/get-user.handler',  environment: { TABLE_NAME: usersTable.tableName },});
const processJobWorker = factory.createWorkerHandler({  id: 'ProcessJobWorker',  handler: 'dist/workers/process-job.handler',  environment: { QUEUE_URL: jobQueue.queueUrl },});

This factory centralizes common Lambda configuration (VPC, layers, security groups) while allowing customization per function type. Without the factory, every Lambda definition would repeat the same 10-15 lines.

Modern Alternative: Discriminated Unions

TypeScript's discriminated unions enable type-safe factories:

typescript
type LoggerConfig =  | { type: 'console'; colorize: boolean }  | { type: 'file'; path: string; maxSize: number }  | { type: 'cloudwatch'; logGroup: string; region: string };
function createLogger(config: LoggerConfig): Logger {  switch (config.type) {    case 'console':      return new ConsoleLogger(config.colorize);    case 'file':      return new FileLogger(config.path, config.maxSize);    case 'cloudwatch':      return new CloudWatchLogger(config.logGroup, config.region);  }}
// TypeScript ensures correct properties for each typeconst logger = createLogger({  type: 'file',  path: '/var/log/app.log',  maxSize: 10485760, // 10MB  // TypeScript error if we add 'colorize' here});

The compiler verifies each configuration branch has exactly the right properties. No runtime errors from missing or incorrect options.

Factory Functions with Type Guards

Combine factories with type guards for runtime type checking:

typescript
type DatabaseConfig = PostgresConfig | MySQLConfig | SQLiteConfig;
interface PostgresConfig {  type: 'postgres';  socketPath: string;  database: string;}
interface MySQLConfig {  type: 'mysql';  host: string;  port: number;  connectionLimit: number;}
interface SQLiteConfig {  type: 'sqlite';  filename: string;}
function createDatabase(config: DatabaseConfig): Database {  if (config.type === 'postgres') {    return new PostgresDatabase(config.socketPath, config.database);  }  if (config.type === 'mysql') {    return new MySQLDatabase(config.host, config.port, config.connectionLimit);  }  return new SQLiteDatabase(config.filename);}

TypeScript narrows types in each branch, giving you autocomplete and type safety for branch-specific properties.

When Not to Use Factories

Don't create factory functions for trivial object creation:

typescript
// OVERKILL: Factory for simple objectfunction createUser(name: string, email: string): User {  return { name, email };}
// BETTER: Just use object literalconst user: User = { name: 'John', email: '[email protected]' };

Reserve factories for conditional logic, shared configuration, or complex initialization.

The Builder Pattern: When Fluent APIs Beat Options Objects

Builder pattern constructs complex objects step-by-step. In TypeScript, you must decide: builder or options object?

When Options Object Is Better

For simple cases with few optional parameters, options objects are clearer:

typescript
interface LambdaOptions {  handler: string;  runtime?: Runtime;  timeout?: number;  memorySize?: number;  environment?: Record<string, string>;}
const fn = new Lambda({  handler: 'index.handler',  runtime: Runtime.NODEJS_20_X,  timeout: 30,  memorySize: 1024,  environment: { TABLE_NAME: 'users' },});

This is clean, type-safe, and self-documenting. No builder needed.

When Builder Adds Value

Builder pattern shines for complex objects with dependencies and progressive configuration:

typescript
class StepFunctionsWorkflow {  private states: State[] = [];  private errorHandler?: ErrorHandler;
  addState(name: string, state: State): this {    this.states.push({ name, ...state });    return this;  }
  addParallelStates(name: string, branches: State[][]): this {    this.states.push({      name,      type: 'Parallel',      branches,    });    return this;  }
  onError(handler: (builder: ErrorPathBuilder) => ErrorPathBuilder): this {    const errorPath = new ErrorPathBuilder();    this.errorHandler = handler(errorPath).build();    return this;  }
  build(): StateMachine {    if (this.states.length === 0) {      throw new Error('Workflow must have at least one state');    }
    return {      states: this.states,      errorHandler: this.errorHandler,      startAt: this.states[0].name,    };  }}
// Usage shows progressive disclosureconst workflow = new StepFunctionsWorkflow()  .addState('ValidateInput', {    type: 'Task',    resource: validateLambda.functionArn,  })  .addParallelStates('ProcessData', [    [      {        type: 'Task',        resource: scanVirusLambda.functionArn,        retry: [{ errorEquals: ['States.TaskFailed'], maxAttempts: 3 }],      },    ],    [      {        type: 'Task',        resource: extractMetadataLambda.functionArn,        timeout: 60,      },    ],    [      {        type: 'Task',        resource: generateThumbnailLambda.functionArn,        resultPath: '$.thumbnail',      },    ],  ])  .addState('StoreResults', {    type: 'Task',    resource: storeLambda.functionArn,  })  .onError((errorPath) =>    errorPath      .addState('LogError', {        type: 'Task',        resource: logErrorLambda.functionArn,      })      .addState('SendAlert', {        type: 'Task',        resource: alertLambda.functionArn,      })  )  .build();

The builder provides:

  • Progressive disclosure: Error handling only available after adding states
  • Fluent API: Method chaining with autocomplete
  • Validation: build() validates complete configuration
  • Complex nesting: Parallel states and error handling compose cleanly

Type-Safe Builder with Generics

Track required fields at compile time:

typescript
type RequiredFields = 'url' | 'method';
class RequestBuilder<TSet extends string = never> {  private config: Partial<RequestConfig> = {};
  url(url: string): RequestBuilder<TSet | 'url'> {    this.config.url = url;    return this as any;  }
  method(method: HttpMethod): RequestBuilder<TSet | 'method'> {    this.config.method = method;    return this as any;  }
  headers(headers: Record<string, string>): this {    this.config.headers = headers;    return this;  }
  timeout(ms: number): this {    this.config.timeout = ms;    return this;  }
  // build() only available when all required fields set  build(this: RequestBuilder<RequiredFields>): Request {    return new Request(this.config as RequestConfig);  }}
// Compile error - missing required fields// const req = new RequestBuilder().build();
// OK - all required fields providedconst req = new RequestBuilder()  .url('https://api.example.com/users')  .method('GET')  .headers({ 'Authorization': 'Bearer token' })  .timeout(5000)  .build();

The generic type parameter tracks which fields have been set. The build() method is only callable when TSet includes all required fields.

Builder with Factory Methods

Combine patterns for common configurations:

typescript
class ApiClientBuilder {  private config: Partial<ApiClientConfig> = {};
  // Factory methods for common configurations  static forProduction(apiKey: string): ApiClientBuilder {    return new ApiClientBuilder()      .withApiKey(apiKey)      .withTimeout(30000)      .withRetries(3)      .enableCaching()      .withBaseUrl('https://api.prod.com');  }
  static forDevelopment(apiKey: string): ApiClientBuilder {    return new ApiClientBuilder()      .withApiKey(apiKey)      .withTimeout(60000)      .disableCaching()      .withVerboseLogging()      .withBaseUrl('https://api.dev.com');  }
  withApiKey(key: string): this {    this.config.apiKey = key;    return this;  }
  withTimeout(ms: number): this {    this.config.timeout = ms;    return this;  }
  withRetries(count: number): this {    this.config.retries = count;    return this;  }
  enableCaching(): this {    this.config.cacheEnabled = true;    return this;  }
  disableCaching(): this {    this.config.cacheEnabled = false;    return this;  }
  withVerboseLogging(): this {    this.config.logLevel = 'debug';    return this;  }
  withBaseUrl(url: string): this {    this.config.baseUrl = url;    return this;  }
  build(): ApiClient {    if (!this.config.apiKey) {      throw new Error('API key is required');    }    if (!this.config.baseUrl) {      throw new Error('Base URL is required');    }    return new ApiClient(this.config as ApiClientConfig);  }}
// Quick start with sensible defaultsconst prodClient = ApiClientBuilder.forProduction(process.env.API_KEY);
// Or customize from scratchconst customClient = new ApiClientBuilder()  .withApiKey(process.env.API_KEY)  .withBaseUrl('https://api.custom.com')  .withTimeout(45000)  .withRetries(5)  .build();

When Not to Use Builder

Don't build builders for simple objects:

typescript
// OVERKILLnew UserBuilder()  .withName('John')  .withEmail('[email protected]')  .build();
// BETTERconst user: User = { name: 'John', email: '[email protected]' };

Reserve builders for objects with:

  • 5+ optional parameters
  • Complex validation dependencies
  • Progressive configuration requirements
  • Domain-specific language (DSL) benefits

The Prototype Pattern: Replaced by Object Spread

Prototype pattern creates objects by cloning existing instances. JavaScript's prototypal inheritance makes this pattern less relevant, and modern features have largely replaced it.

Old Approach

Classic prototype cloning:

typescript
class Prototype {  clone(): this {    return Object.create(this);  }}
class ConcretePrototype extends Prototype {  constructor(public data: string) {    super();  }}
const original = new ConcretePrototype('data');const clone = original.clone();

Modern Approach: Object Spread

Object spread handles shallow cloning cleanly:

typescript
const original = {  name: 'John',  age: 30,  address: { city: 'NYC', zip: '10001' },};
// Shallow cloneconst clone = { ...original };
// Modify clone - doesn't affect original's primitive propertiesclone.name = 'Jane';console.log(original.name); // Still 'John'
// But nested objects are sharedclone.address.city = 'LA';console.log(original.address.city); // Also 'LA'

For deep cloning, use structuredClone() (available in Node.js 17+ and widely available since Node 18 LTS, modern browsers):

typescript
const original = {  name: 'John',  age: 30,  address: { city: 'NYC', zip: '10001' },  metadata: {    tags: ['developer', 'typescript'],    preferences: { theme: 'dark' },  },};
// Deep cloneconst deepClone = structuredClone(original);
// Modify nested properties - doesn't affect originaldeepClone.address.city = 'LA';deepClone.metadata.tags.push('react');console.log(original.address.city); // Still 'NYC'console.log(original.metadata.tags); // Still ['developer', 'typescript']

structuredClone() handles:

  • Nested objects and arrays
  • Dates, RegExp, Map, Set
  • Typed arrays
  • Cyclic references

It doesn't handle:

  • Functions
  • DOM nodes
  • Symbols
  • Prototypes (creates plain objects)

React State Updates: Immutability Pattern

React state updates demonstrate practical cloning:

typescript
const [state, setState] = useState({  count: 0,  items: ['apple', 'banana'],  user: { name: 'John', role: 'admin' },});
// Shallow clone with modificationsetState(prev => ({ ...prev, count: prev.count + 1 }));
// Deep clone for nested structuressetState(prev => ({  ...prev,  items: [...prev.items, 'cherry'],  user: { ...prev.user, role: 'user' },}));

When Prototype Pattern Still Relevant

Test data builders benefit from prototype-like cloning:

typescript
class UserBuilder {  private template: Partial<User> = {    role: 'user',    verified: false,    createdAt: new Date(),    preferences: { theme: 'light', notifications: true },  };
  fromTemplate(template: Partial<User>): this {    this.template = { ...this.template, ...template };    return this;  }
  asAdmin(): this {    return this.fromTemplate({      role: 'admin',      permissions: ['read', 'write', 'delete'],    });  }
  asVerified(): this {    return this.fromTemplate({ verified: true });  }
  withEmail(email: string): this {    return this.fromTemplate({ email });  }
  build(): User {    return {      id: crypto.randomUUID(),      email: `user-${Date.now()}@example.com`,      ...this.template,    } as User;  }}
// Create base admin templateconst adminTemplate = new UserBuilder().asAdmin().asVerified();
// Clone and customize for different testsconst admin1 = adminTemplate.fromTemplate({ email: '[email protected]' }).build();const admin2 = adminTemplate.fromTemplate({ email: '[email protected]' }).build();
// Each has admin defaults but unique email and ID

This pattern shines in test suites where you need many similar objects with slight variations.

Configuration Templates

Configuration objects benefit from cloning:

typescript
// Base Lambda configurationconst baseLambdaConfig = {  runtime: Runtime.NODEJS_20_X,  timeout: Duration.seconds(30),  memorySize: 1024,  environment: {    LOG_LEVEL: 'info',    REGION: 'us-east-1',  },  vpc: sharedVpc,  securityGroups: [lambdaSecurityGroup],  layers: [commonLayer],};
// Clone and customize for specific handlersconst apiHandler = new Function(this, 'ApiHandler', {  ...baseLambdaConfig,  handler: 'dist/api/handler.handler',  environment: {    ...baseLambdaConfig.environment,    TABLE_NAME: usersTable.tableName,  },});
const workerHandler = new Function(this, 'WorkerHandler', {  ...baseLambdaConfig,  handler: 'dist/worker/handler.handler',  timeout: Duration.minutes(15),  memorySize: 2048,  environment: {    ...baseLambdaConfig.environment,    QUEUE_URL: jobQueue.queueUrl,  },});

Cost Analysis & Trade-offs

Development Complexity

Singleton:

  • ES modules: Low complexity, zero boilerplate
  • DI containers: Medium complexity, requires framework knowledge
  • Classic singleton: Low complexity but testing overhead

Factory:

  • Factory functions: Low complexity, straightforward
  • Factory classes: Medium complexity when configuration is shared
  • Discriminated unions: Low complexity with strong type safety

Builder:

  • Simple builder: Medium complexity, worth it for 5+ optional parameters
  • Type-safe builder: High complexity, justified for public library APIs
  • Immutable builder: Higher memory usage but safer in concurrent scenarios

Prototype:

  • Object spread: Very low complexity
  • structuredClone(): Very low complexity, handles deep cloning
  • Custom cloning logic: Medium complexity, needed for special cases

Runtime Performance

Singleton:

  • Negligible overhead
  • One-time initialization cost
  • Lazy initialization adds small check on each access

Factory:

  • Minimal overhead - just function call
  • No meaningful performance difference vs direct instantiation

Builder:

  • Higher memory usage in immutable builders (creates intermediate objects)
  • Mutable builders have minimal overhead
  • Method chaining has negligible performance cost

Prototype:

  • Object spread: Fast for shallow cloning
  • structuredClone(): Slower than spread but handles deep cloning correctly
  • Performance matters only for large objects or high-frequency cloning

Testing Impact

Singleton:

  • Major testing challenge with classic singleton (shared state between tests)
  • Module-based singletons mockable via module mocks
  • DI containers make testing straightforward

Factory:

  • Easy to test - pure functions or injectable dependencies
  • Discriminated unions testable with all branches

Builder:

  • Excellent for creating test fixtures
  • Progressive configuration means testing each build step

Prototype:

  • Easy to test - pure data transformation
  • No side effects or hidden state

Bundle Size

Singleton:

  • Minimal code for ES module approach
  • DI containers add 5-10KB (InversifyJS ~15.6KB gzipped)

Factory:

  • Small - just functions or lightweight classes

Builder:

  • Each builder method adds to bundle
  • Immutable builders larger than mutable
  • Type-safe builders compile away (zero runtime cost)

Prototype:

  • Negligible - using built-in language features

Practical Guidelines

Use ES Modules Instead of Singleton When:

  • Simple stateful service (logger, config manager)
  • No need for lazy initialization
  • Working in Node.js or modern bundlers

Use Factory Functions When:

  • Simple conditional creation
  • No shared initialization logic
  • Stateless object creation

Use Factory Classes When:

  • Complex shared initialization (AWS CDK constructs)
  • Multiple related factory methods
  • Configuration reused across many instances

Use Builder Pattern When:

  • 5+ optional parameters
  • Progressive configuration needed
  • Complex validation dependencies
  • Building domain-specific language (DSL)

Use Options Object Instead of Builder When:

  • Less than 5 optional parameters
  • No progressive configuration needed
  • No complex validation

Use Object Spread When:

  • Cloning plain objects
  • Shallow clones suffice
  • React state updates

Use structuredClone() When:

  • Deep cloning needed
  • Nested objects with no functions
  • Handling cyclic references

Common Pitfalls

Pitfall 1: Singleton for Everything

Problem: Making every service a singleton because "we only need one instance."

Solution: Use dependency injection. Let the DI container control lifecycle, not the class:

typescript
// DON'T: Singleton serviceclass EmailService {  private static instance: EmailService;  static getInstance() { /* ... */ }}
// DO: Injectable service@injectable()class EmailService {  constructor(    @inject('MailProvider') private mail: MailProvider,    @inject('Config') private config: EmailConfig  ) {}}
// DI container manages lifecyclecontainer.bind(EmailService).toSelf().inSingletonScope();

Pitfall 2: Factory Functions That Don't Add Value

Problem: Creating factory functions for trivial object creation.

Solution: Use constructors or object literals for simple cases:

typescript
// OVERKILLfunction createUser(name: string, email: string): User {  return { name, email };}
// BETTERconst user: User = { name: 'John', email: '[email protected]' };

Pitfall 3: Builder Pattern for Simple Objects

Problem: Complex builders for objects with 2-3 properties.

Solution: Reserve builders for genuinely complex objects:

typescript
// OVERKILLnew UserBuilder()  .withName('John')  .withEmail('[email protected]')  .build();
// BETTERconst user: User = { name: 'John', email: '[email protected]' };

Pitfall 4: Ignoring TypeScript's Type System

Problem: Implementing patterns without leveraging TypeScript's features.

Solution: Use discriminated unions for factories, conditional types for builders, module scope for singletons:

typescript
// Leverage discriminated unionstype DatabaseConfig =  | { type: 'postgres'; connectionString: string }  | { type: 'mysql'; host: string; port: number }  | { type: 'sqlite'; filename: string };
function createDatabase(config: DatabaseConfig): Database {  // TypeScript narrows types in each branch  switch (config.type) {    case 'postgres':      return new PostgresDatabase(config.connectionString);    case 'mysql':      return new MySQLDatabase(config.host, config.port);    case 'sqlite':      return new SQLiteDatabase(config.filename);  }}

Pitfall 5: Module Singleton Misunderstandings

Problem: Assuming module singletons work everywhere.

Lesson: Module singletons don't work well with:

  • Hot module replacement (HMR) in development - module reloads create new instances
  • Server-side rendering (SSR) - each request should have isolated state
  • Testing - shared state leaks between tests

Solution: Use factories or DI containers for these scenarios:

typescript
// For SSR: Create instance per requestexport function createRequestContext(req: Request): RequestContext {  return new RequestContext(req);}
// Middleware creates context per requestapp.use((req, res, next) => {  req.context = createRequestContext(req);  next();});
// Each request has isolated context

Pitfall 6: Deep Cloning When Unnecessary

Problem: Using structuredClone() or deep clone libraries for everything.

Solution: Shallow cloning with object spread is often sufficient and much faster:

typescript
// UNNECESSARY: Deep clone when shallow worksconst user = structuredClone(originalUser);
// BETTER: Shallow cloneconst user = { ...originalUser };
// NECESSARY: Deep clone when modifying nested objectsconst config = structuredClone(baseConfig);config.database.host = 'localhost'; // Won't affect baseConfig

Key Takeaways

  1. Singleton Is Usually a Module: ES modules provide singleton behavior naturally. Reserve explicit Singleton pattern for cases requiring lazy initialization or complex lifecycle management.

  2. Factory Functions First: Start with simple factory functions. Escalate to factory classes only when shared state or complex initialization logic justifies additional structure.

  3. Builder for Complexity, Not Convention: Don't build builders reflexively. Use them when fluent APIs improve discoverability or when complex validation requires progressive configuration.

  4. Prototype Died With ES6: Object spread and structuredClone() replaced Prototype pattern for most use cases. The principle (cloning over instantiation) remains valuable for test data and configuration templates.

  5. TypeScript Changes the Game: Classical patterns assumed limited type systems. TypeScript's discriminated unions, conditional types, and type inference encode pattern benefits at compile time without runtime overhead.

  6. Dependency Injection Over Singleton: Modern architectures favor DI containers over Singleton pattern. This improves testability and flexibility without sacrificing single-instance semantics.

  7. Pattern Principles Over Implementation: The problems patterns solve remain relevant. The specific implementations from 1994 may not. Focus on understanding the underlying problem, then choose the most idiomatic modern solution.

  8. Context Matters: Patterns aren't universally good or bad. Singleton is problematic in server request handlers but fine for application-wide loggers. Builder is overkill for simple objects but invaluable for complex AWS CDK constructs.

The creational patterns haven't disappeared - they've evolved. Modern TypeScript gives you language features that solve many problems patterns addressed in 1994. Recognize when patterns add value and when language features suffice. Your codebase will be simpler and more maintainable for it.

References

Modern Perspective on Classic Design Patterns

A comprehensive series examining how classic Gang of Four design patterns have evolved in modern TypeScript, React, and functional programming contexts. Learn when classic patterns still apply, when they've been superseded, and how to recognize underlying principles in modern codebases.

Progress1/4 posts completed

Related Posts