Create objects without new
-noise, kill switch
jungles, and make your codebase easier to test and extend with modern JavaScript (and a pinch of TypeScript).

new
-noise, kill switch
jungles, and make your codebase easier to test and extend with modern JavaScript (and a pinch of TypeScript).Introduction: Why factories matter in 2025
You’ve probably seen this shape somewhere in your code:
switch (kind) {
case "stripe": return new StripeClient(cfg);
case "paypal": return new PaypalClient(cfg);
default: throw new Error("Unknown kind");
}
It works… until it doesn’t. New variants require touching this file, tests stub half a dozen constructors, and soon “one quick switch” becomes an inflexible, brittle spiderweb.
The Factory Pattern fixes this by centralizing creation logic, decoupling clients from concrete classes, and making extension safe. In modern JS/TS, factories can be tiny functions, closures, or class methods, no ceremony required.
This guide shows clean, practical factory patterns with copy-pasteable code:
- Simple factory functions
- Abstract factories & registries
- Typed factories (TS)
- Testing & DI with factories
- Real UI / API / worker examples
- Anti-patterns and a migration checklist
If you’re a JS/TS dev who cares about clarity, testability, and extensibility, you’ll use something here this week.
What is a Factory (in modern JS terms)?
A factory is any function (or object) that creates configured instances while hiding construction details.
Why it helps
- Decoupling: Callers don’t import concrete classes (or know constructor quirks).
- Consistency: One place to enforce defaults, guards, and invariants.
- Testability: Swap real deps with fakes by injecting a different factory.
- Extensibility: Add new variants without changing calling code.
Signs you need a factory
- Repeating
new Something(config)
across files with slight variations. - Big
switch/if
logic to pick implementations. - Hard-to-test code because construction has side effects.
- “God constructors” with too many parameters.
Taxonomy (30-second mental model)
- Factory Function (most common): A plain function returning configured objects/closures.
- Factory Method: A method on a class that creates related objects.
- Abstract Factory: A factory that creates families of related objects (e.g., themed UI components).
- Registry-based Factory: A pluggable map of keys → constructors/factories (great for plugins).
In JS, start with simple functions. Reach for abstract/registry patterns when variants multiply.
Example 1: A configurable Logger Factory (closure > class)
// loggerFactory.js
export function createLogger({ level = "info", sink = console } = {}) {
const levels = ["debug", "info", "warn", "error"];
const threshold = levels.indexOf(level);
function shouldLog(lvl) {
return levels.indexOf(lvl) >= threshold;
}
return {
debug: (...args) => shouldLog("debug") && sink.debug("[debug]", ...args),
info: (...args) => shouldLog("info") && sink.info("[info ]", ...args),
warn: (...args) => shouldLog("warn") && sink.warn("[warn ]", ...args),
error: (...args) => shouldLog("error") && sink.error("[error]", ...args),
};
}
// usage
const log = createLogger({ level: "warn" });
log.info("hidden"); // not printed
log.error("oops"); // printed
Why it clean
- Callers don’t care about log internals; they just ask for a logger.
- Easily swap sinks (e.g., write to file, network).
- Tests can inject a fake
sink
and assert calls, without spying on.console
.
Test snippet
const calls = [];
const sink = { debug: (...a)=>calls.push(["d",a]),
info: (...a)=>calls.push(["i",a]),
warn: (...a)=>calls.push(["w",a]),
error: (...a)=>calls.push(["e",a]) };
const log = createLogger({ level: "info", sink });
log.debug("x"); // ignored
log.warn("y"); // recorded
// expect(calls).toEqual([["w",["[warn ]","y"]]])
Example 2: HTTP client factory (base URL, headers, retries)
// httpFactory.js
export function createHttp({ baseURL = "", headers = {}, retries = 0 } = {}) {
async function request(path, init = {}) {
const url = baseURL + path;
let attempt = 0, lastErr;
while (attempt <= retries) {
try {
const res = await fetch(url, {
...init,
headers: { ...headers, ...(init.headers || {}) },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res;
} catch (err) {
lastErr = err;
attempt++;
if (attempt > retries) throw lastErr;
await new Promise(r => setTimeout(r, 200 * attempt)); // simple backoff
}
}
}
return {
get: (p) => request(p),
json: async (p, i) => (await request(p, i)).json(),
post: (p, body) => request(p, { method: "POST", body: JSON.stringify(body),
headers: { "Content-Type":"application/json" } }),
};
}
// usage
const api = createHttp({ baseURL: "http://localhost:8080", headers: { Authorization: "Bearer dev" }, retries: 1 });
const users = await api.json("/users");
Why it clean
- One place to enforce headers, base URL, and retry policy.
- Call sites are tiny; changing policy is a one-line configuration change.
Upgrade path: add interceptors, auth refresh, or metrics inside the factory without touching callers.
Example 3: A tiny UI Component Abstract Factory (Vanilla DOM)
Create a family of themed components from one factory:
// uiFactory.js
export function createUI(theme = "light") {
const palette = theme === "dark"
? { bg: "#111", fg: "#eee", accent: "#6cf" }
: { bg: "#fff", fg: "#111", accent: "#06f" };
const button = (label, onClick) => {
const el = document.createElement("button");
el.textContent = label;
Object.assign(el.style, {
background: palette.accent, color: palette.bg, border: "none",
padding: "8px 12px", borderRadius: "8px", cursor: "pointer",
});
el.addEventListener("click", onClick);
return el;
};
const card = (contentEl) => {
const el = document.createElement("div");
Object.assign(el.style, {
background: palette.bg, color: palette.fg, padding: "16px",
borderRadius: "12px", boxShadow: "0 8px 24px rgba(0,0,0,.1)"
});
el.appendChild(contentEl);
return el;
};
return { button, card };
}
// usage
const ui = createUI("dark");
document.body.appendChild(
ui.card(ui.button("Click me", () => alert("hi")))
);
Why it clean
- Theme decisions are centralized.
- Callers ask for a family of components consistent by design.
- Swapping themes is one parameter.
Example 4: Payment processors with a Registry Factory (plugins)
Avoid giant switch
blocks by registering new processors.
// paymentRegistry.js
const registry = new Map();
export function registerProcessor(name, factory) {
registry.set(name, factory);
}
export function createProcessor(name, options) {
const factory = registry.get(name);
if (!factory) throw new Error(`Unknown processor "${name}"`);
return factory(options);
}
// stripePlugin.js
import { registerProcessor } from "./paymentRegistry.js";
registerProcessor("stripe", ({ key }) => ({
charge: async (cents) => {/* ... */ console.log("stripe charge", cents, key) }
}));
// paypalPlugin.js
import { registerProcessor } from "./paymentRegistry.js";
registerProcessor("paypal", ({ token }) => ({
charge: async (cents) => {/* ... */ console.log("paypal charge", cents, token) }
}));
// usage
import "./stripePlugin.js";
import "./paypalPlugin.js";
import { createProcessor } from "./paymentRegistry.js";
const p = createProcessor("stripe", { key: "sk_dev" });
await p.charge(499);
Why it clean
- Adding a new provider = a new file that registers itself.
- No central switch to modify → Open/Closed Principle wins.
- Testing becomes easy: register a test double.
Example 5: Schema-driven validator factory
// validatorFactory.js
export function createValidator(schema) {
const rules = Object.entries(schema); // { field: (value) => boolean | string }
return (obj) => {
const errors = {};
for (const [field, validate] of rules) {
const msg = validate(obj[field]);
if (msg !== true) errors[field] = msg || "Invalid";
}
return { ok: Object.keys(errors).length === 0, errors };
};
}
// usage
const userValidator = createValidator({
email: v => /\S+@\S+\.\S+/.test(v) || "Invalid email",
age: v => (Number(v) >= 18) || "Must be 18+",
});
const result = userValidator({ email: "a@b.com", age: 17 });
// { ok:false, errors:{ age: "Must be 18+" } }
Why it clean
- One function to create many similar validators.
- Tests pass in edge cases; no need to mock global libs.
- Later, you can swap the internals to Zod/Joi without changing call sites.
Example 6: Web Worker factory (safe, repeatable offloads)
// workerFactory.js
export function createWorker(fn) {
const blob = new Blob([`onmessage = async (e) => {
const fn = ${fn.toString()};
const result = await fn(e.data);
postMessage(result);
}`], { type: "text/javascript" });
const worker = new Worker(URL.createObjectURL(blob));
return {
run: (payload) => new Promise((resolve) => {
const handler = (e) => { worker.removeEventListener("message", handler); resolve(e.data); };
worker.addEventListener("message", handler);
worker.postMessage(payload);
}),
terminate: () => worker.terminate(),
};
}
// usage
const fibWorker = createWorker((n) => {
const f = (x) => (x <= 1 ? x : f(x-1) + f(x-2));
return f(n);
});
const res = await fibWorker.run(30);
console.log(res);
fibWorker.terminate();
Why it clean
- Spin up many consistent workers with a single factory.
- Callers get a simple
run
API.
Example 7: Testable service via injected factories (DI)
// repoFactory.js
export const createMemoryRepo = () => {
const data = new Map();
return {
get: (id) => data.get(id),
set: (id, v) => data.set(id, v),
};
};
// userService.js
export function createUserService({ repoFactory }) {
const repo = repoFactory();
return {
create: (u) => repo.set(u.id, u),
get: (id) => repo.get(id),
};
}
// production wiring
const userService = createUserService({ repoFactory: createMemoryRepo });
// test wiring
const fakeRepoFactory = () => ({
get: jest.fn(), set: jest.fn(),
});
const svc = createUserService({ repoFactory: fakeRepoFactory });
Why it clean
- Service doesn’t know or care which repo it uses.
- Tests pass a fake factory; no filesystem/network.
Example 8: File importer registry (CSV, JSON, YAML)
// importers.js
const importers = new Map();
export const registerImporter = (ext, factory) => importers.set(ext, factory);
export const createImporter = (ext, options) => {
const f = importers.get(ext);
if (!f) throw new Error(`No importer for .${ext}`);
return f(options);
};
// csv.js
import { registerImporter } from "./importers.js";
registerImporter("csv", () => ({
parse: (text) => text.trim().split("\n").map(row => row.split(",")),
}));
// json.js
import { registerImporter } from "./importers.js";
registerImporter("json", () => ({
parse: (text) => JSON.parse(text),
}));
// usage
import "./csv.js"; import "./json.js";
import { createImporter } from "./importers.js";
const importer = createImporter("csv");
const rows = importer.parse("a,b\n1,2");
Add a new format by registering a new file, no core changes.
TypeScript: ergonomic, type-safe factories
// tytpes.ts
export interface Http {
get: (p: string) => Promise<Response>;
json: <T = unknown>(p: string, init?: RequestInit) => Promise<T>;
post: <T = unknown>(p: string, body: unknown) => Promise<Response>;
}
export interface HttpOptions {
baseURL?: string;
headers?: Record<string,string>;
retries?: number;
}
// httpFactory.ts
export function createHttp(opts: HttpOptions = {}): Http {
const { baseURL = "", headers = {}, retries = 0 } = opts;
async function request(path: string, init: RequestInit = {}) { /* same as JS */ }
return {
get: (p) => request(p),
json: async <T>(p, i) => (await request(p, i)).json() as Promise<T>,
post: (p, body) => request(p, { method: "POST", body: JSON.stringify(body), headers: { "Content-Type":"application/json" } }),
};
}
// usage
type User = { id:number; name:string };
const api = createHttp({ baseURL: "/api" });
const users = await api.json<User[]>("/users");
Why TS helps
- Callers get typed payloads without caring how the client is built.
- Factories become contracts you can mock in tests.
Practical guidelines (what seniors actually do)
Do
- Start with a plain function factory (closure).
- Keep the factory thin; push business logic into the instances.
- Centralize defaults and validation in the factory.
- Provide interfaces/types for what the factory returns (TS or JSDoc).
- Use the registry when variants are dynamic (plugins, integrations).
- Inject factories into services for testability.
Avoid
- “God factories” that know the whole app.
- Hiding the global state in factories passes in dependencies explicitly.
- Over-abstracting: if there’s only one concrete type and no duplication, don’t factory it “just because”.
Migration recipe: refactor away from switch
& new
soup
- Identify hotspots: duplicate constructors, long switches.
- Extract creation into a
createX
function with sane defaults. - Replace call sites to use the factory.
- If you have multiple variants, introduce a registry:
register("stripe", stripeFactory)
in its own filecreate("stripe", opts)
where needed
5. Write tests against the factory’s interface; add fakes.
6. Delete old switches and constructor imports from call sites.
Performance notes (quick reality check)
- The overhead of a small factory function is negligible compared to I/O, DOM, or React rerenders.
- If you build millions of small objects per second, measure them, and consider pre-allocating or pooling. For most web apps, factories are not the bottleneck.
FAQ (rapid-fire)
Is a factory just a wrapper around new
?
Often yes, plus defaults, guards, and a clean interface. Many modern factories don’t use classes at all; they return closures.
Factory vs Builder?
Factory: which concrete variant to create.
Builder: How to assemble a complex object step by step. They compose nicely.
Factory vs Singleton?
Different concerns: Singleton limits count to one; factory focuses on construction decoupling. You can have a factory that returns a singleton instance.
Copy-paste checklist
- ✅ Name factories consistently:
createX
,makeX
, orXFactory
. - ✅ Return interfaces, not classes (easier to mock).
- ✅ Centralize defaults.
- ✅ Prefer pure functions/closures over classes when possible.
- ✅ For variants, use a registry (no giant switches).
- ✅ Add tiny smoke tests per factory.
Conclusion
The Factory Pattern in modern JS is not about design-pattern trivia, it’s a tool for clean construction:
- Callers don’t care how things are made.
- You get one place for defaults, guards, and wiring.
- Tests become trivial.
- New variants land without touching old code.
Start small: extract a createHttp
, createLogger
, or createStore
. Once you feel the difference in clarity and tests, you’ll reach for factories on autopilot.
Call to Action
What part of your codebase would benefit most from a factory right now: API client, logger, or integrations? Drop your candidate in the comments 👇
👉 Share this with a teammate who’s fighting a 200-line switch
.
🔖 Bookmark for your next refactor session.
Leave a Reply