Creational patterns
Learn factory functions, singletons, builders, modules, revealing modules, and cloning choices for production JavaScript architecture.
- 01Choose the right creation shapeExplain when a factory, singleton, builder, module, clone, or copy constructor makes object creation clearer.
- 02Trace real JavaScript implementationsStep through closure-backed factories, lazy singletons, and fluent builders without relying on folklore.
- 03Avoid pattern damageSpot hidden global state, shallow copies,
thispitfalls, and over-engineered pattern names before they hurt tests.
Patterns are shared vocabulary
A design pattern is a name for a recurring design shape. It helps a team say “use a factory here” or “this singleton is making tests hard” without explaining the whole trade-off every time. It is vocabulary, not a law.
The famous Gang of Four book was written for C++ and Smalltalk. JavaScript has first-class functions, closures, object literals, prototypes, and ES modules, so some classic patterns become tiny. A module singleton can be one exported object. A private object can be a factory with a closure. A builder might be unnecessary when an options object with defaults is enough.
Creational patterns name ways to create, configure, share, and copy objects. In this lesson you will build factory functions, singletons, builders, module patterns, and clone/copy strategies with real JavaScript functions.
Think of a workshop. The order desk sends each job to the right station, the tool cabinet is shared, the assembly line performs steps in order, and the stencil copies a shape. The labels help you choose where a job belongs.
- In real life: Order desk chooses a station
- In JavaScript: A factory chooses an implementation
- In real life: One shared tool cabinet
- In JavaScript: A singleton is one shared instance
- In real life: Assembly line checks each step
- In JavaScript: A builder validates in
build() - In real life: A stencil copies a shape
- In JavaScript: Clone and copy rules decide what survives
Where the analogy stops: A workshop has physical tools that cannot be imported twice. JavaScript modules are evaluated once by the module system, while factory calls can create as many fresh objects as you need.
| Shape | How it creates | Good fit | Watch out |
|---|---|---|---|
| Factory function | A normal function returns a ready-to-use object. | Variants, private closure state, avoiding new and this mistakes. | Every call may create a new object and new methods unless you share prototypes deliberately. |
| Class | new calls a constructor and methods usually live on the prototype. | Shared methods, instanceof, inheritance, and public class APIs. | Forgetting new, binding methods incorrectly, or overusing inheritance. |
| Constructor function | A pre-class pattern: new Fn() assigns to this, with methods on Fn.prototype. | Reading legacy code and understanding what classes compile to conceptually. | Easy to call without new and accidentally write to the wrong this in sloppy code. |
| Singleton | One shared instance is reused by everyone. | Config, loggers, feature flags, and process-level adapters. | Hidden global state makes tests order-dependent unless callers can inject a fake. |
| Builder | A fluent object collects choices, then build() validates and returns the result. | Queries, test data builders, complex request objects, and readable setup. | Too much ceremony when an options object with defaults is enough. |
You will use ideas from object basics, constructors and new, class basics, closures, closure patterns, Object.create, private members, why modules exist, import and export, and immutability.
Factory functions
CHANGE INPUTSA factory function is a plain function that returns an object. It can hide private state in a closure, return different implementations from the same call shape, and avoid the two common constructor mistakes: forgetting new and losing this.
function createCounter(label) { let count = 0; return { label, next() { count += 1; return label + ": " + count; }, reset() { count = 0; }, };} const first = createCounter("factory");const second = createCounter("factory");console.log(first.next());console.log(first.next());console.log(second.next());console.log(first === second);Read it line by line. Line 1 declares an ordinary function. Line 2 creates a fresh count for that one call. Lines 5 through 8 return a method that can still reach that private variable. Lines 15 and 16 call the factory twice, so the final identity check is false even though both counters use the same label.
function createUser(name) { return { name, greet() { return "Hello, " + name; }, };} class ClassUser { constructor(name) { this.name = name; } greet() { return "Hello, " + this.name; }} function ConstructorUser(name) { this.name = name;}ConstructorUser.prototype.greet = function () { return "Hello, " + this.name;}; console.log(createUser("Ada").greet());console.log(new ClassUser("Lin").greet());console.log(new ConstructorUser("Grace").greet());All three snippets create objects. The factory closes over name. The class uses new and stores name on this. The constructor function is the older form: new ConstructorUser() sets up this and the prototype method.
function createNotifier(channel) { if (channel === "email") { return { send(message) { return "email: " + message; }, }; } if (channel === "sms") { return { send(message) { return "sms: " + message; }, }; } return { send(message) { return "toast: " + message; }, };} console.log(createNotifier("email").send("Build passed"));console.log(createNotifier("sms").send("Deploy now"));createNotifier("email")email: Build passedcreateNotifier(email) returns an object with the same send method shape, but the behavior now prints email: Build passed.
The notifier is a factory that chooses among implementations. A larger “abstract factory” uses the same idea to create a whole family of related objects: for example, one UI kit factory could return matching createButton and createDialog functions. That broader architecture belongs with later structural lessons; here the key is simple: one creation function can hide the branch and return a compatible public shape.
Singletons
STEP THROUGHA singleton is one shared instance. JavaScript often does not need a formal class for this: an ES module is evaluated once, so a module-level object exported from one file is shared by importers. A lazy singleton delays creation until someone asks for it.
A lazy singleton creates one shared object on the first call, then returns the same object every time after that. Step through both calls and watch the second argument get ignored.
script
function getConfig(env) { if (!configInstance) { configInstance = Object.freeze({ env, apiBase: "https://" + env + ".example.com", retries: 2, }); } return configInstance;} const first = getConfig("prod");const second = getConfig("staging");console.log(first === second);console.log(second.env);console.log(Object.isFrozen(first));function createCounter(label) { let count = 0; return { next() { count += 1; return label + ": " + count; }, };} let configInstance;function getConfig(env) { if (!configInstance) configInstance = { env }; return configInstance;} const firstCounter = createCounter("box");const secondCounter = createCounter("box");const firstConfig = getConfig("prod");const secondConfig = getConfig("staging");console.log(firstCounter === secondCounter);console.log(firstCounter.next());console.log(secondCounter.next());console.log(firstConfig === secondConfig);console.log(secondConfig.env);falsebox: 1box: 1Two factory calls produce two different objects. Their first next() calls both start at 1 because each closure owns a separate count.
Singletons are fine for read-only config, a process logger, or a feature-flag client. They hurt when tests need different state, because the dependency is hidden behind an import or a getter. Passing a logger or config into a function is dependency injection, the same idea used in mocks and fakes.
Object.freeze can protect the top-level properties of a config object. It does not make the design testable by itself, and it is shallow unless nested objects are frozen or copied too. Prefer a singleton only when sharing is the point.
Builders
STEP THROUGHA builder is useful when construction has steps. Each method records one choice and returns this or a new builder. The final build() method validates the collected state and returns the finished value.
A builder collects choices over several method calls, then validates and returns the finished object or string in build().
script
const state = { table, fields: ["*"], filters: [], limit: null, }; return { select(...fields) { state.fields = fields; return this; }, where(field, value) { state.filters.push({ field, value }); return this; }, limit(count) { state.limit = count; return this; }, build() { if (!state.table) throw new Error("table is required"); if (state.limit !== null && state.limit <= 0) { throw new Error("limit must be positive"); } const fields = state.fields.join(", "); const where = state.filters.length ? " WHERE " + state.filters.map((filter) => filter.field + " = " + JSON.stringify(filter.value) ).join(" AND ") : ""; const limit = state.limit === null ? "" : " LIMIT " + state.limit; return "SELECT " + fields + " FROM " + state.table + where + limit; }, };} const query = createQueryBuilder("users") .select("id", "email") .where("active", true) .limit(2) .build();console.log(query);This query builder is intentionally small, but it shows the real shape. select, where, and limit collect state. build() rejects a missing table or invalid limit before returning a string. A production query builder would also escape identifiers, use parameters, and avoid hand-written SQL strings.
function createApiClient({ baseUrl = "/api", timeout = 3000, headers = {},} = {}) { return { baseUrl, timeout, headers };} const fastClient = createApiClient({ timeout: 1000 });console.log(fastClient.baseUrl);console.log(fastClient.timeout);Many JavaScript APIs should stop here. A named options object with defaults is lightweight, works well with parameters and destructuring, and avoids a fluent object when there are no real steps to validate.
Module and revealing module
HISTORICALBefore ES modules were everywhere, JavaScript code often used an IIFE: an immediately invoked function expression. The wrapper created private variables, and the returned object revealed only the public functions.
const cartModule = (() => { const items = []; function add(name, price) { items.push({ name, price }); } function total() { return items.reduce((sum, item) => sum + item.price, 0); } return { add, total };})(); cartModule.add("Book", 12);cartModule.add("Pen", 3);console.log(cartModule.total());console.log(cartModule.items);This is a revealing module because add and total are private function declarations first, then the returned object exposes references to them. The items array is private because it is never returned.
let currentUser = null; export function setCurrentUser(user) { currentUser = user;} export function getCurrentUser() { return currentUser;}Modern modules make this the default: top-level bindings are private to the file unless you export them. Because ES modules are evaluated once per module graph, a module-level object is also the usual JavaScript singleton. Review why modules exist and import and export when that model feels fuzzy.
Cloning and copying
REAL COPIESThe prototype pattern creates new objects from an existing prototype. Copying asks a more precise question: what should survive in the copy? Own properties, nested data, class methods, private fields, or the prototype chain?
const widgetPrototype = { describe() { return this.kind + ":" + this.theme; }, clone(overrides = {}) { return Object.assign( Object.create(Object.getPrototypeOf(this)), this, overrides ); },}; const primary = Object.assign(Object.create(widgetPrototype), { kind: "button", theme: "primary",});const danger = primary.clone({ theme: "danger" });console.log(primary.describe());console.log(danger.describe());console.log(Object.getPrototypeOf(danger) === widgetPrototype);The clone() method uses Object.create(Object.getPrototypeOf(this)) so the copy has the same prototype as the original. Object.assign copies own properties and applies overrides.
class Point { constructor(xOrPoint, y) { if (typeof xOrPoint === "object") { this.x = xOrPoint.x; this.y = xOrPoint.y; } else { this.x = xOrPoint; this.y = y; } } clone() { return new Point(this.x, this.y); } toString() { return this.x + "," + this.y; }} const original = new Point(2, 3);const cloned = original.clone();const copied = new Point(original);console.log(cloned.toString());console.log(copied.toString());console.log(copied instanceof Point);A clone() method is called on the old object. A copy constructor is called as new Point(other). Both can be shallow or deep; the object or class decides the rule.
const original = { user: { name: "Ada" }, tags: ["js"],};const spread = { ...original };spread.user.name = "Lin";spread.tags = [...spread.tags, "patterns"];console.log(original.user.name);console.log(original.tags.join(","));console.log(spread.tags.join(","));Spreading copies top-level properties. The nested user object is still shared, so changing spread.user.name also changes original.user.name. Replacing spread.tags with a new array does not mutate the original array.
class Point { constructor(x, y) { this.x = x; this.y = y; } distance() { return Math.hypot(this.x, this.y); }} const point = new Point(3, 4);const copied = structuredClone(point);console.log(copied instanceof Point);console.log(typeof copied.distance);console.log(JSON.stringify(copied));structuredClone is useful for supported data such as arrays, plain objects, dates, maps, and sets. It does not preserve custom class prototypes. The copied point has x and y, but copied instanceof Point is false and copied.distance is undefined.
| Strategy | Depth | Best use |
|---|---|---|
| Spread copy | Shallow | Plain objects and arrays when nested objects can stay shared or are replaced deliberately. |
Object.create(proto) | Prototype-preserving | Prototype pattern objects that want a clone() method and shared behavior. |
clone() method | Class or object decides | The object owns its own copy rules, including private or derived fields. |
| Copy constructor | Class decides | new Point(other) is explicit and keeps the prototype because the constructor creates a real instance. |
structuredClone | Deep for supported data | Dates, Maps, Sets, arrays, and plain data; class instances come back as plain objects. |
Where these appear in real projects
SORT ITProduction code rarely announces “I am using a pattern.” It says createApiClient, logger, queryBuilder, aUser(), or clone(). The pattern helps you discuss the shape and trade-off.
| Use case | Likely shape | Why |
|---|---|---|
| API clients | Factory or options object | createApiClient({ baseUrl, token }) keeps environment details outside call sites. |
| Runtime config | Module-level singleton | A frozen config object is fine when it is read-only and created once at startup. |
| Query builders | Builder | Step-by-step filters are readable, and build() can validate before sending SQL or URL params. |
| Test data | Builder or factory | aUser().withRole("admin").build() keeps tests expressive while still returning plain objects. |
| UI plugins | Abstract factory idea | One factory can return a family of related objects, such as matching modal and button implementations. |
createNotifier(channel)returns an email, SMS, or toast sender.getConfig()returns the same frozen object after the first call.`query.select("id").where("active", true).build()``const p2 = new Point(p1);``createCounter("cart")` returns an object with private `count`.`userBuilder().withRole("admin").build()``const copy = structuredClone(settings);``export const logger = createLogger();`
Sort each card by the creation problem it solves. Look for fresh objects, shared instances, fluent building, or copying.
Common misconceptions
- “Patterns are mandatory architecture.” Patterns are names. Use the smallest JavaScript shape that solves the problem.
- “Factories are just classes without
class.” Factories can choose implementations and use closure privacy; classes emphasize prototypes and instances. - “A singleton is safe if it is frozen.” Freezing protects top-level writes; it does not remove hidden global state or reset tests.
- “Every chain is a builder.” A builder collects construction state and has a meaningful
build(). Some chains are just fluent helpers. - “
structuredCloneis the perfect clone.” It is deep for supported data, but class instances lose their custom prototype.
| Misconception | Quick check | Better wording |
|---|---|---|
| Factory vs class is only syntax | Does creation choose an implementation or need closure privacy? | Factories and classes optimize for different call sites. |
| Singleton equals global variable | Is sharing intentional, read-only, and easy to replace in tests? | A singleton is a controlled shared instance, but still shared state. |
| Builder is always cleaner | Would an options object with defaults say the same thing? | Use builders for real steps and validation. |
| Deep copy keeps class methods | Does the copy still pass instanceof and have prototype methods? | Deep data copy and instance copy are different promises. |
Practice exercises
5 EXERCISESType the three printed values in order.
function makeBox(label) {
let value = 0;
return {
add() {
value += 1;
return label + ":" + value;
},
};
}
const first = makeBox("cart");
const second = makeBox("cart");
first.add();
console.log(first.add());
console.log(second.add());
console.log(first === second);The first counter reaches cart:2. The second counter has its own closure, so it prints cart:1. The objects are different, so the final value is false.
Read the branch and type the exact text printed.
function createNotifier(channel) {
return channel === "sms"
? { send: (message) => "sms: " + message }
: { send: (message) => "email: " + message };
}
console.log(createNotifier("sms").send("Ready"));The factory chooses the SMS object, so calling send("Ready") prints sms: Ready.
Type the URL printed by the builder.
function createUrlBuilder(path) {
const params = [];
return {
param(name, value) {
params.push(name + "=" + encodeURIComponent(value));
return this;
},
build() {
return path + "?" + params.join("&");
},
};
}
console.log(createUrlBuilder("/users").param("role", "admin user").build());The builder collects one encoded parameter, then build() returns /users?role=admin%20user.
Type the three printed values in order.
const original = { nested: { count: 1 }, tags: ["a"] };
const copy = { ...original };
copy.nested.count += 1;
copy.tags = [...copy.tags, "b"];
console.log(original.nested.count);
console.log(original.tags.join(""));
console.log(copy.tags.join(""));The nested count is shared, so the original sees 2. The original tags stay a; the copy tags become ab.
Type the two values printed by the clone check.
class Widget {
constructor(name) {
this.name = name;
}
label() {
return "widget:" + this.name;
}
}
const copied = structuredClone(new Widget("menu"));
console.log(copied instanceof Widget);
console.log(typeof copied.label);The cloned object is plain data, not a Widget, so the output is false and undefined.
Check your understanding
7 QUESTIONSQuestion 1 of 7What are design patterns in this lesson?
Choose an answer to see the explanation.
Question 2 of 7What does the factory counter print?
Read the code, then predictfunction makeCounter() { let count = 0; return { next: () => ++count }; } const a = makeCounter(); const b = makeCounter(); console.log(a.next()); console.log(b.next()); console.log(a === b);Choose an answer to see the explanation.
Question 3 of 7What does the lazy singleton identity check print?
Read the code, then predictlet instance; function getLogger(name) { if (!instance) instance = { name }; return instance; } const first = getLogger("app"); const second = getLogger("test"); console.log(first === second); console.log(second.name);Choose an answer to see the explanation.
Question 4 of 7What does this builder print?
Read the code, then predictfunction makeBuilder() { const parts = []; return { add(value) { parts.push(value); return this; }, build() { return parts.join("/"); }, }; } console.log(makeBuilder().add("api").add("users").build());Choose an answer to see the explanation.
Question 5 of 7What does the revealing module hide?
Read the code, then predictconst module = (() => { const secret = "inside"; function reveal() { return secret; } return { reveal }; })(); console.log(module.reveal()); console.log(module.secret);Choose an answer to see the explanation.
Question 6 of 7What happens when
structuredClonecopies a class instance?Read the code, then predictclass Point { constructor(x) { this.x = x; } double() { return this.x * 2; } } const copy = structuredClone(new Point(4)); console.log(copy instanceof Point); console.log(typeof copy.double);Choose an answer to see the explanation.
Question 7 of 7Which creation shape is usually the lightweight alternative to a builder?
Choose an answer to see the explanation.
Key takeaways
- Patterns are vocabulary for trade-offs, not rules to force into code.
- Factories return fresh objects and can use closures or choose implementations.
- Singletons are shared instances; use them for intentional sharing and inject dependencies when tests need control.
- Builders are for step-by-step construction with validation; options objects are the lightweight default.
- Cloning strategies differ in depth and prototype preservation; prove the behavior with real identity checks.
Remember the one-liner.
Choose the simplest creation shape that makes state, identity, validation, and copying rules obvious.
Up next: Observer and pub/sub shows how parts of an app react to changes without tight coupling. Later lessons connect these ideas to structural and behavioral patterns, state management, and app architecture.