Creational Design Patterns in TypeScript: Singleton, Factory, Builder, Prototype
How Singleton, Factory, Builder, and Prototype patterns evolved in TypeScript: when ES modules replace singletons and when factory functions beat classes.
Object instantiation in TypeScript rarely needs a Gang of Four pattern anymore. The default is the language feature: an ES module for a single shared instance, a factory function for conditional construction, object spread or structuredClone() for copies. Wrapping new in a class hierarchy that a module export already handles adds abstraction, harms testability, and obscures intent.
Singleton, Factory, Builder, and Prototype still earn their place, but the trigger is narrower than the textbooks suggest: shared configuration that many objects must apply consistently, or construction that has to be validated in steps.
Singleton Without the Class#
The Singleton pattern ensures a class has only one instance with global access. In 1994, this solved a genuine problem. In TypeScript it is usually an anti-pattern today, with a few narrow exceptions.
The Classic Problem#
Here’s the textbook singleton everyone learns:
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
}
}
// Usage
const db = DatabaseConnection.getInstance();
await db.query('SELECT * FROM users');
This works, but it makes testing hard. You can’t easily mock the instance, can’t inject different configurations, and each test shares global state.
ES Modules as Natural Singletons#
ES modules are cached after first import. Importing the same module multiple times returns the same instance:
// db-connection.ts
class DatabaseConnection {
constructor(private config: DatabaseConfig) {
// Setup logic
}
query(sql: string): Promise<any> {
// Database operations
}
}
// Single instance exported
export const db = new DatabaseConnection({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
});
// other-file.ts
import { db } from './db-connection';
await db.query('SELECT * FROM users');
// another-file.ts
import { 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.
Where Module Singletons Break Down#
Module-level singletons assume one long-lived instance, and that assumption breaks in three environments: hot module replacement in development reloads the module and creates a new instance, server-side rendering needs an isolated instance per request, and tests leak state through the shared instance from one run to the next. A factory function or a DI container that creates a fresh instance per call handles all three:
// For SSR: Create instance per request
export function createRequestContext(req: Request): RequestContext {
return new RequestContext(req);
}
// Middleware creates context per request
app.use((req, res, next) => {
req.context = createRequestContext(req);
next();
});
// Each request has isolated context
Dependency Injection Containers#
For complex applications, dependency injection containers manage object lifecycles:
import { injectable, inject, container } from 'tsyringe';
@injectable()
class DatabaseConnection {
constructor(
@inject('DatabaseConfig') private config: DatabaseConfig
) {
// Setup logic
}
}
// Register as singleton
container.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: you swap implementations in the container.
Logger and Feature-Flag Configuration#
Some scenarios genuinely benefit from explicit singleton pattern:
Logger with configured transports:
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 startup
const logger = Logger.getInstance();
logger.addTransport(new ConsoleTransport());
logger.addTransport(new FileTransport('/var/log/app.log'));
// Use everywhere without configuration
logger.log('info', 'Application started');
Feature flag manager:
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 startup
await FeatureFlags.getInstance().initialize();
// Check anywhere
if (FeatureFlags.getInstance().isEnabled('new-dashboard')) {
// Show new dashboard
}
Singleton for Injectable Dependencies#
Don’t use singleton for dependencies that should be injected:
// DON'T: Hard to test, can't swap implementations
class 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 parameters
class ApiClient {
constructor(
private config: ApiConfig,
private httpClient: HttpClient
) {}
async get(endpoint: string): Promise<any> {
return this.httpClient.get(`${this.config.baseUrl}${endpoint}`);
}
}
// Production
const apiClient = new ApiClient(
{ baseUrl: 'https://api.prod.com' },
new HttpClient()
);
// Testing
const apiClient = new ApiClient(
{ baseUrl: 'http://localhost:3000' },
new MockHttpClient()
);
Factory Functions vs Factory Classes#
The 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:
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 checking
const logger = createLogger('debug');
TypeScript’s exhaustive checking ensures you handle all cases. If you add a log level, the compiler catches missing implementations.
Shared Configuration Across Lambda Handlers#
Factory classes make sense when creation logic requires shared configuration:
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 stack
const 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.
Discriminated Union Factories#
TypeScript’s discriminated unions enable type-safe factories:
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 type
const 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, catching missing or incorrect options before they become runtime errors.
Factory Functions with Type Guards#
Combine factories with type guards for runtime type checking:
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.
Progressive Construction with Builders#
The Builder pattern constructs complex objects step-by-step. In TypeScript, you must decide between a builder and an options object.
When Options Object Is Better#
For simple cases with few optional parameters, options objects are clearer:
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.
State-by-State Workflow Construction#
The Builder pattern earns its place on complex objects with dependencies and progressive configuration:
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 disclosure
const 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:
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 provided
const 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:
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 defaults
const prodClient = ApiClientBuilder.forProduction(process.env.API_KEY);
// Or customize from scratch
const customClient = new ApiClientBuilder()
.withApiKey(process.env.API_KEY)
.withBaseUrl('https://api.custom.com')
.withTimeout(45000)
.withRetries(5)
.build();
Reserve the builder for objects with 5+ optional parameters, complex validation dependencies, progressive configuration requirements, or a domain-specific language (DSL) that reads better as method chains than as a single object literal.
Replacing Prototype with Object Spread#
The Prototype pattern creates objects by cloning existing instances. JavaScript’s prototypal inheritance already covers most of that ground, and modern language features handle the rest.
Cloning via Object.create()#
Classic prototype cloning:
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();
Object Spread for Shallow Clones#
Object spread handles shallow cloning cleanly:
const original = {
name: 'John',
age: 30,
address: { city: 'NYC', zip: '10001' },
};
// Shallow clone
const clone = { ...original };
// Modify clone - doesn't affect original's primitive properties
clone.name = 'Jane';
console.log(original.name); // Still 'John'
// But nested objects are shared
clone.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):
const original = {
name: 'John',
age: 30,
address: { city: 'NYC', zip: '10001' },
metadata: {
tags: ['developer', 'typescript'],
preferences: { theme: 'dark' },
},
};
// Deep clone
const deepClone = structuredClone(original);
// Modify nested properties - doesn't affect original
deepClone.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)
Immutable React State Updates#
React state updates demonstrate practical cloning:
const [state, setState] = useState({
count: 0,
items: ['apple', 'banana'],
user: { name: 'John', role: 'admin' },
});
// Shallow clone with modification
setState(prev => ({ ...prev, count: prev.count + 1 }));
// Deep clone for nested structures
setState(prev => ({
...prev,
items: [...prev.items, 'cherry'],
user: { ...prev.user, role: 'user' },
}));
Test Fixtures with Shared Defaults#
Test data builders benefit from prototype-like cloning:
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 template
const adminTemplate = new UserBuilder().asAdmin().asVerified();
// Clone and customize for different tests
const admin1 = adminTemplate.fromTemplate({ email: 'admin1@example.com' }).build();
const admin2 = adminTemplate.fromTemplate({ email: 'admin2@example.com' }).build();
// Each has admin defaults but unique email and ID
Trade-offs by Pattern#
| Pattern | Complexity | Performance | Testing | Bundle size |
|---|---|---|---|---|
| Singleton | ES modules: zero boilerplate. DI containers: medium, needs framework knowledge. Classic singleton: low, but carries testing overhead. | Negligible; lazy initialization adds a small check on each access. | Classic singleton shares state between tests. Module-based singletons mock through module mocks. DI containers make it straightforward. | ES module approach is minimal. DI containers add real weight; check the container’s published bundle size before shipping it to a browser. |
| Factory | Functions: low, straightforward. Classes: medium once configuration is shared. Discriminated unions: low complexity with strong type safety. | Just a function call; no meaningful difference from direct instantiation. | Pure functions or injectable dependencies test easily. Discriminated unions are testable branch by branch. | Small: just functions or lightweight classes. |
| Builder | Worth the medium complexity past 5+ optional parameters. Type-safe generics push it to high complexity, justified for public library APIs. Immutable builders cost more memory but are safer under concurrency. | Immutable builders create intermediate objects; mutable builders and plain method chaining add negligible overhead. | Builders make excellent test fixtures; progressive configuration means testing each build step. | Every builder method adds to the bundle. Immutable builders are larger than mutable ones. Type-safe builders compile away entirely (zero runtime cost). |
| Prototype | Object spread and structuredClone() are both very low complexity; custom cloning logic is needed only for special cases. | Spread is fast for shallow clones; structuredClone() is slower but handles deep cloning correctly. Performance matters only for large objects or high-frequency cloning. | Easy: pure data transformation, no side effects or hidden state. | Negligible: built-in language features only. |
The Default and Its Limits#
The default holds for most application code: export the instance from a module, write a factory function when construction branches, spread an object when you need a copy. TypeScript’s discriminated unions and generic constraints cover at compile time what the 1994 implementations had to enforce at runtime.
Override the default in three situations. Per-request isolation (SSR, request-scoped context) rules out module singletons, so create instances through a factory or a DI container. Shared infrastructure configuration applied across many objects, such as the VPC, layers, and security groups on a set of Lambda functions, justifies a factory class. Construction that must be validated in steps, or that reads better as a small DSL, justifies a builder. Outside those three, the language feature does the same job with less code to maintain.
References#
- Creational Design Patterns - Refactoring.Guru (opens in new tab) - Overview of GoF creational patterns with intent, structure, and applicability for each
- Design Patterns in TypeScript - Refactoring.Guru (opens in new tab) - TypeScript code examples for all classic GoF patterns
- TypeScript Handbook - Classes (opens in new tab) - Official reference for TypeScript class syntax, access modifiers, and parameter properties
- TypeScript Handbook - Generics (opens in new tab) - Generic type parameters and constraints relevant to factory and builder patterns
- TypeScript Handbook - Utility Types (opens in new tab) - Built-in mapped types (Partial, Readonly, Required) that replace certain creational abstractions
- structuredClone() - MDN (opens in new tab) - Deep-clone semantics, the transfer option, and which values raise DataCloneError
- Node.js Globals - structuredClone() (opens in new tab) - Records that the global arrived in Node.js v17.0.0 and follows the WHATWG method
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.
All posts in this series
Related posts
How SOLID principles apply to modern JavaScript: practical examples with TypeScript, React hooks, and functional patterns, plus when they're overkill.
typescript · javascript · react +4
How Decorator, Adapter, Facade, Composite, and Proxy patterns evolved in React and TypeScript: when HOCs give way to hooks and how adapters isolate third-party APIs.
typescript · react · design-patterns +2
A comprehensive introduction to Domain-Driven Design: core concepts, building blocks, strategic patterns, and when and how to apply DDD in practice.
domain-driven-design · architecture · design-patterns +2
A lifecycle test for CDK stack layout: give a resource its own long-lived stack when it outlives any single deployer, then reach it by a well-known name.
aws-cdk · infrastructure-as-code · typescript +3
Match architecture weight to each runtime's init-amortization: lean handlers on single-purpose Lambda, more on a Lambdalith, full OOP/DI only on long-lived runtimes.
architecture · lambda · serverless +3