Construct an HTTP request. It might have a body, or headers, or a timeout, or retries, or none of those. Almost everything is optional and the combinations are open-ended.
What people write first
One constructor with every parameter.
new Request('POST', '/orders', body, headers, 30_000, 3, true, undefined, 'json');Nobody can read that. Which argument is the timeout? What is true? And the
signature grows every time an option is added, breaking every existing call.
The usual escape is telescoping constructors — one per plausible combination — which multiplies rather than solves.
The other escape is an options object, and in TypeScript that is genuinely good:
new Request({method: 'POST', url: '/orders', body, timeout: 30_000});Named, optional, order-independent. For most objects, stop here. Builder is for the cases this does not cover, and they are narrower than the pattern’s popularity suggests.
The pattern
Accumulate in a separate object; construct once at the end.
class RequestBuilder { private parts: Partial<RequestSpec> = {};
method(m: Method) { this.parts.method = m; return this; } // ← return this url(u: string) { this.parts.url = u; return this; } header(k: string, v: string) { (this.parts.headers ??= {})[k] = v; // accumulates return this; } timeout(ms: number) { this.parts.timeout = ms; return this; }
build(): Request { if (!this.parts.url) throw new Error('url is required'); if (this.parts.method === 'GET' && this.parts.body) { throw new Error('GET cannot have a body'); // ← cross-field rule } return new Request(this.parts as RequestSpec); }}Toggle the parts and watch both sides change:
the call
select("*")
.from("orders")
.where("status", "paid")
.orderBy("created_at", "desc")
.limit(20)
.build()what it built
SELECT * FROM orders WHERE status = 'paid' ORDER BY created_at DESC LIMIT 20
Every step returns the builder, so the calls chain. Nothing is constructed until build() — which is what lets the object be validated once, when
it is complete, rather than after each setter.
Immutability comes free
Because nothing is constructed until build(), the product can have no setters
at all:
class Request { readonly method: Method; readonly url: string; // …all readonly, all assigned once in the constructor}This is the strongest argument for the pattern in a language without named arguments. You get readable construction and an object that cannot be mutated afterwards — normally a trade-off.
The other Builder
The catalogue’s version is different from the fluent one above, and it is worth knowing because it solves a different problem.
There, a director knows the construction sequence, and multiple builders produce different representations from it:
function buildDocument(builder: DocBuilder) { // the director: one sequence builder.title('Report'); builder.paragraph('…'); builder.table(rows); return builder.result();}
buildDocument(new HtmlBuilder()); // HTMLbuildDocument(new MarkdownBuilder()); // Markdown, same sequenceThis is genuinely useful for parsers and serialisers — one traversal, several output formats — and it is what a SAX handler is. Most people saying “builder” mean the fluent version, so it is worth being explicit about which you mean.
When not to
If the object has three fields and no rules between them, a builder is ceremony. The trigger is optionality plus rules: many optional parts, and constraints that only make sense once you can see all of them.
And in TypeScript specifically, check whether an options object plus a
Readonly<> type gets you there first. It usually does.