Skip to content
TypeScript ts patterns 4 min read

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.

ApproachBest whenDrawback
switch factorySmall, fixed set of typesEdits grow with new types
Registry mapMany or plugin-supplied typesLoses exhaustiveness checking
Abstract factoryFamilies that must matchMore 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 switch to 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.
Last updated June 29, 2026
Was this helpful?