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:
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:
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:
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:
Feature flag manager:
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:
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'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:
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:
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 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:
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:
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:
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:
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:
When Not to Use Builder
Don't build builders for simple objects:
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:
Modern Approach: Object Spread
Object spread handles shallow cloning cleanly:
For deep cloning, use structuredClone() (available in Node.js 17+ and widely available since Node 18 LTS, modern browsers):
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:
When Prototype Pattern Still Relevant
Test data builders benefit from prototype-like cloning:
This pattern shines in test suites where you need many similar objects with slight variations.
Configuration Templates
Configuration objects benefit from cloning:
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:
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:
Pitfall 3: Builder Pattern for Simple Objects
Problem: Complex builders for objects with 2-3 properties.
Solution: Reserve builders for genuinely complex objects:
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:
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:
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:
Key Takeaways
-
Singleton Is Usually a Module: ES modules provide singleton behavior naturally. Reserve explicit Singleton pattern for cases requiring lazy initialization or complex lifecycle management.
-
Factory Functions First: Start with simple factory functions. Escalate to factory classes only when shared state or complex initialization logic justifies additional structure.
-
Builder for Complexity, Not Convention: Don't build builders reflexively. Use them when fluent APIs improve discoverability or when complex validation requires progressive configuration.
-
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. -
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.
-
Dependency Injection Over Singleton: Modern architectures favor DI containers over Singleton pattern. This improves testability and flexibility without sacrificing single-instance semantics.
-
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.
-
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
- typescriptlang.org - TypeScript Handbook and language reference.
- github.com - TypeScript project wiki (FAQ and design notes).
- developer.mozilla.org - MDN Web Docs (web platform reference).
- semver.org - Semantic Versioning specification.
- ietf.org - IETF RFC index (protocol standards).
- arxiv.org - arXiv software engineering recent submissions (research context).
- cheatsheetseries.owasp.org - OWASP Cheat Sheet Series (applied security guidance).
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.