Factory Pattern in TypeScript
Learn how to use the Factory pattern to create objects without exposing instantiation logic, with practical TypeScript examples.
The Factory pattern is one of the most useful creational patterns. It provides an interface for creating objects without specifying the exact class to instantiate. This is especially powerful in TypeScript where you can leverage the type system for type-safe factories.
The Problem
Without a factory, object creation logic leaks everywhere:
// ❌ Creation logic scattered across the codebase
function handleNotification(type: string, data: NotificationData) {
let notification;
if (type === 'email') {
notification = new EmailNotification(
data.recipient,
data.subject,
new HTMLRenderer(),
new SMTPTransport(config.smtp),
);
} else if (type === 'sms') {
notification = new SMSNotification(
data.phone,
new TwilioClient(config.twilio),
);
} else if (type === 'push') {
notification = new PushNotification(
data.deviceToken,
new FirebaseClient(config.firebase),
);
}
notification?.send();
}
Adding a new notification type means hunting down every if/else chain. Testing requires real service connections.
Simple Factory
The simplest form — a function or class that encapsulates object creation:
interface Notification {
send(message: string): Promise<void>;
}
class EmailNotification implements Notification {
constructor(
private recipient: string,
private transport: EmailTransport,
) {}
async send(message: string): Promise<void> {
await this.transport.deliver({
to: this.recipient,
subject: 'Notification',
body: message,
});
}
}
class SMSNotification implements Notification {
constructor(
private phone: string,
private client: SMSClient,
) {}
async send(message: string): Promise<void> {
await this.client.sendSMS(this.phone, message);
}
}
class PushNotification implements Notification {
constructor(
private token: string,
private firebase: FirebaseClient,
) {}
async send(message: string): Promise<void> {
await this.firebase.sendPush(this.token, { body: message });
}
}
// The Factory
class NotificationFactory {
constructor(private config: AppConfig) {}
create(type: string, recipient: string): Notification {
switch (type) {
case 'email':
return new EmailNotification(
recipient,
new SMTPTransport(this.config.smtp),
);
case 'sms':
return new SMSNotification(
recipient,
new TwilioClient(this.config.twilio),
);
case 'push':
return new PushNotification(
recipient,
new FirebaseClient(this.config.firebase),
);
default:
throw new Error(`Unknown notification type: ${type}`);
}
}
}
// Usage is clean and simple
const factory = new NotificationFactory(config);
const notification = factory.create('email', '[email protected]');
await notification.send('Your order has shipped!');
Type-Safe Factory with TypeScript
Leverage TypeScript’s type system to make factories that catch errors at compile time:
// Define a registry of types
interface NotificationMap {
email: { recipient: string; subject?: string };
sms: { phone: string };
push: { deviceToken: string; badge?: number };
}
type NotificationType = keyof NotificationMap;
class TypedNotificationFactory {
create<T extends NotificationType>(
type: T,
params: NotificationMap[T],
): Notification {
const creators: Record<NotificationType, (p: any) => Notification> = {
email: (p) => new EmailNotification(p.recipient, this.smtpTransport),
sms: (p) => new SMSNotification(p.phone, this.smsClient),
push: (p) => new PushNotification(p.deviceToken, this.firebaseClient),
};
return creators[type](params);
}
}
// TypeScript enforces correct params for each type
const factory = new TypedNotificationFactory();
factory.create('email', { recipient: '[email protected]' }); // ✅
factory.create('sms', { phone: '+1234567890' }); // ✅
factory.create('email', { phone: '+1234567890' }); // ❌ Type error!
factory.create('webhook', { url: '...' }); // ❌ Type error!
Abstract Factory
When you need to create families of related objects:
// Abstract factory for UI components
interface UIFactory {
createButton(label: string): Button;
createInput(placeholder: string): Input;
createCard(title: string): Card;
}
interface Button {
render(): string;
}
interface Input {
render(): string;
}
interface Card {
render(): string;
}
// Material Design family
class MaterialFactory implements UIFactory {
createButton(label: string): Button {
return new MaterialButton(label);
}
createInput(placeholder: string): Input {
return new MaterialInput(placeholder);
}
createCard(title: string): Card {
return new MaterialCard(title);
}
}
// Bootstrap family
class BootstrapFactory implements UIFactory {
createButton(label: string): Button {
return new BootstrapButton(label);
}
createInput(placeholder: string): Input {
return new BootstrapInput(placeholder);
}
createCard(title: string): Card {
return new BootstrapCard(title);
}
}
// Code that uses the factory works with ANY UI kit
function buildForm(factory: UIFactory): string {
const nameInput = factory.createInput('Enter your name');
const emailInput = factory.createInput('Enter your email');
const submitBtn = factory.createButton('Submit');
return `
${nameInput.render()}
${emailInput.render()}
${submitBtn.render()}
`;
}
Factory Function Pattern
In TypeScript, you don’t always need classes. Factory functions are often simpler:
// Factory function with closures
function createLogger(prefix: string, level: LogLevel = 'info') {
const shouldLog = (msgLevel: LogLevel) =>
LOG_LEVELS.indexOf(msgLevel) >= LOG_LEVELS.indexOf(level);
return {
info: (msg: string) => shouldLog('info') &&
console.log(`[${prefix}] INFO: ${msg}`),
warn: (msg: string) => shouldLog('warn') &&
console.warn(`[${prefix}] WARN: ${msg}`),
error: (msg: string, err?: Error) => shouldLog('error') &&
console.error(`[${prefix}] ERROR: ${msg}`, err),
};
}
// Clean, simple usage
const logger = createLogger('UserService', 'warn');
logger.info('This is ignored'); // Below threshold
logger.warn('This is logged'); // Logged
logger.error('This too'); // Logged
When to Use Factories
- Complex construction: Object needs multiple dependencies or configuration
- Conditional creation: Different implementations based on config or runtime conditions
- Decoupling: Callers shouldn’t know about concrete implementations
- Testing: Need to swap real implementations for test doubles
When NOT to Use Factories
- Simple objects:
new User(name, email)doesn’t need a factory - One implementation: If there’s only ever one concrete class, a factory adds indirection without value
- Prototyping: When you’re exploring, keep it simple and refactor to factories later
“The Factory Method pattern defers instantiation to subclasses, while Abstract Factory provides an interface for creating families of related objects.” — Design Patterns: Elements of Reusable Object-Oriented Software