// ───────────────────────────────────────────────────────────────────────────── // BUILDER [Creational] // ───────────────────────────────────────────────────────────────────────────── // INTENT : Separate the construction of a complex object from its representation so the same // construction process can create different representations. // PROBLEM : A constructor with 8 optional params (telescoping ctor / giant options bag), or an object that // must be assembled step by step and validated before it is usable. // SAY : Participants: Builder (step interface), ConcreteBuilder (accumulates parts, build()/getResult()), // Director (runs a known recipe of steps), Product. Pitch: "fluent steps, then build()." // SEEN IN : Knex / Kysely query builders (`.select().where().orderBy()`); Zod/Yup schemas // (`z.string().min(3).email()`); superagent/supertest request chains (`.get().set().query()`); // webpack-chain config; `new URL()` + `searchParams.append()` assembly; Testing fixtures // (`aUser().withRole('admin').build()`). // VS : Factory returns the product in ONE call; Builder is many calls then build(). Abstract Factory // picks a family; Builder assembles one complex thing. A fluent interface alone isn't Builder // unless a distinct product comes out at the end. // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── Classic shape (classes, so the participant names are visible) class SqlQuery { // Product constructor({ table, columns, where, order, limit }) { Object.assign(this, { table, columns, where, order, limit }); } toString() { const parts = [`SELECT ${this.columns.join(', ')} FROM ${this.table}`]; if (this.where.length) parts.push(`WHERE ${this.where.map(([c, op]) => `${c} ${op} ?`).join(' AND ')}`); if (this.order) parts.push(`ORDER BY ${this.order}`); if (this.limit != null) parts.push(`LIMIT ${this.limit}`); return parts.join(' '); } get params() { return this.where.map(([, , v]) => v); } } class SelectBuilder { // ConcreteBuilder — steps return `this`; build() validates #table = null; #columns = ['*']; #where = []; #order = null; #limit = null; from(table) { this.#table = table; return this; } select(...cols) { this.#columns = cols; return this; } where(col, op, val) { this.#where.push([col, op, val]); return this; } orderBy(expr) { this.#order = expr; return this; } limit(n) { this.#limit = n; return this; } build() { if (!this.#table) throw new Error('from() is required'); return new SqlQuery({ table: this.#table, columns: this.#columns, where: this.#where, order: this.#order, limit: this.#limit }); } } class ReportQueries { // Director — knows the recipe, not the representation static recentPaidOrders(builder, days) { return builder.from('orders').select('id', 'total') .where('status', '=', 'paid').where('created_at', '>', `now() - ${days}d`) .orderBy('created_at DESC').limit(50).build(); } } const q = ReportQueries.recentPaidOrders(new SelectBuilder(), 7); assert.equal(String(q), 'SELECT id, total FROM orders WHERE status = ? AND created_at > ? ORDER BY created_at DESC LIMIT 50'); assert.deepEqual(q.params, ['paid', 'now() - 7d']); // parameterized — the builder kept values separate assert.equal(String(new SelectBuilder().from('users').build()), 'SELECT * FROM users'); assert.throws(() => new SelectBuilder().select('id').build(), /from\(\) is required/); // ── Idiomatic JS shape: immutable closure-based fluent builder → frozen plain object const requestBuilder = (state = { method: 'GET', headers: {}, query: {} }) => ({ method: (m) => requestBuilder({ ...state, method: m }), url: (u) => requestBuilder({ ...state, url: u }), header: (k, v) => requestBuilder({ ...state, headers: { ...state.headers, [k.toLowerCase()]: v } }), query: (k, v) => requestBuilder({ ...state, query: { ...state.query, [k]: String(v) } }), json: (body) => requestBuilder({ ...state, body: JSON.stringify(body), headers: { ...state.headers, 'content-type': 'application/json' }, }), build: () => { if (!state.url) throw new Error('url required'); const qs = new URLSearchParams(state.query).toString(); return Object.freeze({ ...state, url: qs ? `${state.url}?${qs}` : state.url }); }, }); const base = requestBuilder().url('https://api.example.com/orders').header('Authorization', 'Bearer t'); const list = base.query('page', 2).build(); const create = base.method('POST').json({ sku: 'A1', qty: 3 }).build(); assert.equal(list.url, 'https://api.example.com/orders?page=2'); assert.equal(list.method, 'GET'); assert.equal(create.headers['content-type'], 'application/json'); assert.equal(create.headers.authorization, 'Bearer t'); assert.equal(create.body, '{"sku":"A1","qty":3}'); assert.equal(base.build().url, 'https://api.example.com/orders'); // immutable: base unaffected by branches assert.ok(Object.isFrozen(create)); assert.throws(() => requestBuilder().method('GET').build(), /url required/); console.log('ok: design-patterns/creational/builder.js');