Factory Pattern
The Factory pattern moves the decision of which concrete class to instantiate out of the calling code and into a dedicated function or class. Callers ask the factory for “a thing that satisfies this interface” and receive a ready-to-use object without ever naming a concrete constructor. This decouples consumers from implementations, makes adding new variants painless, and centralizes any wiring or validation that creation requires. TypeScript’s interfaces, union types, and the satisfies operator make factories both safe and ergonomic. This page covers the factory function, the abstract factory, and a registry-driven approach.
The problem it solves
When code uses new ConcreteClass() directly, it is welded to that class. Supporting a second implementation means editing every call site, and the branching logic for choosing a type gets scattered everywhere.
// ❌ Choice logic duplicated wherever a notifier is needed
const notifier =
channel === "email" ? new EmailNotifier() : new SmsNotifier();
A factory collapses that decision into one place, so the rest of the app depends only on an interface.
A simple factory function
The most idiomatic factory in TypeScript is just a function that returns a value typed by an interface. Define the contract, implement the variants, and let one function map an input to the right implementation.
interface Notifier {
send(to: string, message: string): Promise<void>;
}
class EmailNotifier implements Notifier {
async send(to: string, message: string): Promise<void> {
console.log(`Email to ${to}: ${message}`);
}
}
class SmsNotifier implements Notifier {
async send(to: string, message: string): Promise<void> {
console.log(`SMS to ${to}: ${message}`);
}
}
type Channel = "email" | "sms";
function createNotifier(channel: Channel): Notifier {
switch (channel) {
case "email":
return new EmailNotifier();
case "sms":
return new SmsNotifier();
}
}
Callers write createNotifier("email") and program against Notifier. Because Channel is a finite union, the switch is exhaustively checked — if you add "push" to Channel without a matching case, TypeScript flags the missing branch.
Returning a discriminated result
Factories often build objects whose shape depends on the input. A discriminated union lets the factory return different payloads while keeping the result type-safe to consume.
type Shape =
| { kind: "circle"; radius: number; area: () => number }
| { kind: "square"; side: number; area: () => number };
function createShape(kind: "circle", size: number): Shape;
function createShape(kind: "square", size: number): Shape;
function createShape(kind: Shape["kind"], size: number): Shape {
return kind === "circle"
? { kind, radius: size, area: () => Math.PI * size ** 2 }
: { kind, side: size, area: () => size ** 2 };
}
const c = createShape("circle", 5);
console.log(c.area());
The overload signatures document the valid argument pairings, while the shared implementation builds the correct variant. Consumers narrow on kind to access variant-specific fields safely.
Abstract factory for families of objects
When you need to create families of related objects that must stay consistent (for example, all light-theme or all dark-theme widgets), an abstract factory exposes several creation methods behind one interface.
interface Button { render(): string; }
interface Checkbox { render(): string; }
interface UIFactory {
createButton(): Button;
createCheckbox(): Checkbox;
}
class DarkFactory implements UIFactory {
createButton(): Button { return { render: () => "<button class='dark'>" }; }
createCheckbox(): Checkbox { return { render: () => "<input class='dark'>" }; }
}
function buildForm(factory: UIFactory): string {
return factory.createButton().render() + factory.createCheckbox().render();
}
buildForm works with any UIFactory, so swapping DarkFactory for a LightFactory reskins the whole form without touching the form logic.
Registry-driven factory
For open-ended sets of types, a registry map avoids growing a switch. New variants register themselves, which keeps the factory closed for modification but open for extension.
| Approach | Best when | Drawback |
|---|---|---|
switch factory | Small, fixed set of types | Edits grow with new types |
| Registry map | Many or plugin-supplied types | Loses exhaustiveness checking |
| Abstract factory | Families that must match | More interfaces to maintain |
const registry = new Map<string, () => Notifier>();
registry.set("email", () => new EmailNotifier());
registry.set("sms", () => new SmsNotifier());
function makeNotifier(key: string): Notifier {
const create = registry.get(key);
if (!create) throw new Error(`Unknown notifier: ${key}`);
return create();
}
A registry trades compile-time exhaustiveness for runtime extensibility. Validate the key and fail loudly when it is missing, since the compiler can no longer guarantee every case is handled.
When to use and pitfalls
Use a factory when creation logic is non-trivial, when the concrete type depends on runtime input, or when you want to swap implementations (including test doubles) without touching consumers. Avoid it when a plain constructor would do — wrapping every new in a factory adds indirection for no gain. Keep factories free of business logic; their job is construction, not behaviour.
Best Practices
- Return an interface type, never a concrete class, so callers stay decoupled.
- Use finite union inputs and
switchto get exhaustiveness checking for free. - Prefer plain factory functions over factory classes unless you need shared state.
- Reach for an abstract factory only when related objects must be created as a matching set.
- Use a registry for plugin-style extensibility, and guard against unknown keys.
- Keep construction logic out of consumers and business logic out of the factory.
- Combine with dependency injection: inject the factory so tests can supply fakes.