Skip to content

Factory Pattern and Dynamic Creation

advanced18 min read

The Problem with Direct Construction

You're building a notification system. You have toast notifications, banner notifications, and modal notifications. Each has different rendering logic, different animations, and different dismissal behavior. The naive approach:

function showNotification(type: string, message: string) {
  if (type === "toast") {
    const el = document.createElement("div");
    el.className = "toast";
    el.textContent = message;
    document.body.appendChild(el);
    setTimeout(() => el.remove(), 3000);
  } else if (type === "banner") {
    const el = document.createElement("div");
    el.className = "banner";
    el.innerHTML = `<span>${message}</span><button>Dismiss</button>`;
    document.querySelector("#banner-slot")?.appendChild(el);
  } else if (type === "modal") {
    // ... 20 more lines of modal-specific code
  }
}

Every new notification type means editing this function. The if/else chain grows. Testing is painful because everything is tangled together. The Factory pattern solves this by separating what gets created from how it's used.

Mental Model

Think of the Factory pattern like a restaurant kitchen. You (the customer) order "a burger" from the menu. You don't walk into the kitchen, grab the patty, fire up the grill, and assemble it yourself. The kitchen (factory) takes your order (config) and returns the finished product. If the restaurant changes the recipe, adds a new burger, or swaps the grill — you still just order "a burger" and get the right thing.

Simple Factory Functions

In JavaScript, the simplest factory is just a function that returns an object. No classes needed:

interface Notification {
  show(): void;
  dismiss(): void;
}

function createToast(message: string, duration = 3000): Notification {
  let element: HTMLDivElement | null = null;

  return {
    show() {
      element = document.createElement("div");
      element.className = "toast";
      element.textContent = message;
      document.body.appendChild(element);
      setTimeout(() => this.dismiss(), duration);
    },
    dismiss() {
      element?.remove();
      element = null;
    },
  };
}

function createBanner(message: string): Notification {
  let element: HTMLDivElement | null = null;

  return {
    show() {
      element = document.createElement("div");
      element.className = "banner";
      element.textContent = message;
      document.querySelector("#banner-slot")?.appendChild(element);
    },
    dismiss() {
      element?.remove();
      element = null;
    },
  };
}

Both return a Notification object. The consumer doesn't care which concrete type it gets — it just calls show() and dismiss(). This is the power of programming to an interface.

Quiz
Why do JavaScript factory functions often use closures instead of classes?

The Factory Registry Pattern

The real power comes when you combine a factory with a registry — a map of type names to creation functions. New types are added without touching existing code:

type NotificationType = "toast" | "banner" | "modal";

interface NotificationConfig {
  message: string;
  duration?: number;
  title?: string;
}

type NotificationFactory = (config: NotificationConfig) => Notification;

const registry = new Map<NotificationType, NotificationFactory>();

function registerNotification(
  type: NotificationType,
  factory: NotificationFactory
): void {
  registry.set(type, factory);
}

function createNotification(
  type: NotificationType,
  config: NotificationConfig
): Notification {
  const factory = registry.get(type);
  if (!factory) {
    throw new Error(`Unknown notification type: ${type}`);
  }
  return factory(config);
}

registerNotification("toast", (config) => createToast(config.message, config.duration));
registerNotification("banner", (config) => createBanner(config.message));

Now adding a new notification type is a single registerNotification call. Zero modifications to existing code. This is the Open-Closed Principle in action — open for extension, closed for modification.

// A new team adds modal notifications without touching existing code
registerNotification("modal", (config) => ({
  show() { /* modal logic */ },
  dismiss() { /* modal logic */ },
}));

// Consumer code doesn't change
const notification = createNotification("toast", { message: "Saved!" });
notification.show();
Quiz
What design principle does the Factory Registry pattern enforce?

Production Scenario: API Client Factory

Real-world API clients need different configurations for different environments, auth strategies, and base URLs. A factory encapsulates this complexity:

interface ApiClient {
  get<T>(path: string): Promise<T>;
  post<T>(path: string, body: unknown): Promise<T>;
  put<T>(path: string, body: unknown): Promise<T>;
  delete(path: string): Promise<void>;
}

interface ApiClientConfig {
  baseUrl: string;
  headers?: Record<string, string>;
  timeout?: number;
  retry?: { attempts: number; delay: number };
}

function createApiClient(config: ApiClientConfig): ApiClient {
  const { baseUrl, headers = {}, timeout = 10000, retry } = config;

  async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);

    let lastError: Error | null = null;
    const attempts = retry?.attempts ?? 1;

    for (let i = 0; i < attempts; i++) {
      try {
        const response = await fetch(`${baseUrl}${path}`, {
          method,
          headers: { "Content-Type": "application/json", ...headers },
          body: body ? JSON.stringify(body) : undefined,
          signal: controller.signal,
        });

        clearTimeout(timeoutId);

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        return response.json() as Promise<T>;
      } catch (error) {
        lastError = error as Error;
        if (i < attempts - 1 && retry) {
          await new Promise(r => setTimeout(r, retry.delay * (i + 1)));
        }
      }
    }
    throw lastError;
  }

  return {
    get: <T>(path: string) => request<T>("GET", path),
    post: <T>(path: string, body: unknown) => request<T>("POST", path, body),
    put: <T>(path: string, body: unknown) => request<T>("PUT", path, body),
    delete: (path: string) => request<void>("DELETE", path),
  };
}

Different parts of your app create clients with different configs:

const publicApi = createApiClient({
  baseUrl: "https://api.example.com/v1",
  timeout: 5000,
});

const adminApi = createApiClient({
  baseUrl: "https://api.example.com/admin",
  headers: { Authorization: `Bearer ${token}` },
  timeout: 30000,
  retry: { attempts: 3, delay: 1000 },
});

const users = await publicApi.get<User[]>("/users");
const report = await adminApi.get<Report>("/reports/daily");

Each client is a self-contained unit with its own config, retry logic, and error handling. The factory encapsulates the construction complexity so consumers just call get and post.

React Component Factories

In React, factory patterns show up when you need to create components dynamically based on data:

interface FormField {
  type: "text" | "email" | "select" | "checkbox";
  name: string;
  label: string;
  options?: string[];
  required?: boolean;
}

type FieldComponent = React.FC<{
  name: string;
  label: string;
  options?: string[];
  required?: boolean;
}>;

const fieldComponents: Record<FormField["type"], FieldComponent> = {
  text: ({ name, label, required }) => (
    <label>
      {label}
      <input type="text" name={name} required={required} />
    </label>
  ),
  email: ({ name, label, required }) => (
    <label>
      {label}
      <input type="email" name={name} required={required} />
    </label>
  ),
  select: ({ name, label, options = [] }) => (
    <label>
      {label}
      <select name={name}>
        {options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
      </select>
    </label>
  ),
  checkbox: ({ name, label }) => (
    <label>
      <input type="checkbox" name={name} />
      {label}
    </label>
  ),
};

function createField(field: FormField): React.ReactElement {
  const Component = fieldComponents[field.type];
  return <Component key={field.name} {...field} />;
}

function DynamicForm({ fields }: { fields: FormField[] }) {
  return <form>{fields.map(createField)}</form>;
}

This is how form builders, CMS editors, and dashboard tools work. The form definition is data (JSON from an API), and the factory translates it into components.

Quiz
In the React component factory above, why is the field registry an object literal instead of a switch statement?
What developers doWhat they should do
Creating a factory class with a single create method that contains a giant switch statement
A switch-based factory violates the Open-Closed Principle. Every new type requires modifying the factory. A registry lets you add types externally, which is critical when different teams or plugins need to register their own types
Use a registry Map or Record so new types are added without touching existing code
Returning plain objects from factories without a shared interface
Without a shared interface, consumers must check which concrete type they received, defeating the purpose of the factory. The whole point is that consumers work with the interface, not the implementation
Define a TypeScript interface and ensure all factory products implement it
Using factories for objects that only have one implementation
Factories add indirection. If there is only one notification type and you do not expect more, a factory is unnecessary abstraction. Introduce factories when you have multiple implementations or expect extensibility
Use direct construction when there is only one way to create something

Challenge

Build a createValidator factory that produces validation functions from a declarative schema.

Challenge:

Try to solve it before peeking at the answer.

// Requirements:
// 1. createValidator accepts a schema object
// 2. Returns a validate(data) function
// 3. Schema supports: required, minLength, maxLength, pattern, custom
// 4. Returns { valid: boolean, errors: string[] }

const validateUser = createValidator({
  name: { required: true, minLength: 2, maxLength: 50 },
  email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
  age: { custom: (v: number) => v >= 18 || "Must be 18+" },
});

const result = validateUser({ name: "", email: "bad", age: 16 });
// { valid: false, errors: [
//   "name: minimum length is 2",
//   "email: does not match required pattern",
//   "age: Must be 18+"
// ]}
Key Rules
  1. 1Use factory functions (not classes) in JavaScript — closures give you true privacy and simpler API surfaces
  2. 2Combine factories with a registry (Map or Record) to follow the Open-Closed Principle
  3. 3TypeScript's Record type enforces exhaustiveness — adding a new variant without a factory entry triggers a compile error
  4. 4Factories shine when you have multiple implementations of the same interface, or when construction logic is complex enough to encapsulate