Skip to content

9.4.4 — Builder

What the original Gang of Four book says: Separate how a complex object is built from what it ends up being, so the same building process can produce different results.

What that means when you are actually writing code: When an object has many parts, most of them optional and some of them tied to each other, assemble it in named steps and check the whole thing once at the end, so that no half-built object ever escapes into the rest of your program.

Builder is the pattern for the object that has too many pieces to fit comfortably into a constructor. That happens more often than you would think, because objects gain configuration over time the same way the payment gateway in the last two chapters gained providers. This chapter is about two wins that come together: making construction readable, and making it correct. The readability is what sells the pattern in review; the correctness is what makes it worth having.

1. The story: the line nobody can read

Here is a real shape of code, drawn from a real class of bug:

typescript
const server = new HttpServer("0.0.0.0", 8080, null, true, false, 30_000, null, ["gzip"], true);

Answer these questions without opening the file. Which of the two nulls is the TLS config, and which is the access-log path? That true, false in the middle — is it keepAlive, http2, or is it http2, keepAlive? Is 30_000 the request timeout or the keep-alive timeout?

You cannot answer. Nobody can. This is write-only code: every reader has to go and consult the constructor's signature, and every reader who does not bother, guesses. The specific ways it fails are worth naming, because they recur in every language.

Positional ambiguity. Two boolean arguments sitting next to each other, both the same type, means the two can be swapped with zero complaint from the compiler and a silent change in behaviour. This is the single most common source of the "it worked in staging" configuration bug.

The telescoping constructor. As options pile up, you write constructor(host, port), then you add constructor(host, port, tls), and eventually you have a nine-argument monster. Worse, when a parameter is inserted rather than added at the end, every existing call site has to be re-read and re-checked.

null as "not applicable". Callers pass placeholder nulls for the options they do not care about, just to reach the one argument they do care about. So the constructor cannot tell the difference between "explicitly none" and "I never thought about this".

No home for cross-field rules. TLS on port 80 is nonsense. HTTP/2 without TLS is nonsense for browsers. A compression list that includes br needs a Brotli build to be present. Nothing in the constructor owns these relationships between fields, so each one is either left unchecked or scattered as an if statement in some caller.

Builder exists for that last problem just as much as for the first. The readability win is what sells it; the invariant win is what makes it correct.

2. How you arrive at the pattern

Step 1 — Start naive. A constructor that takes the object's fields. This is correct and best for two or three unambiguous parameters.

Step 2 — Wait for the force. Parameters multiply. Most of them become optional. Some of them acquire relationships with each other. And construction sometimes needs to happen across time or across modules — framework defaults first, then environment overrides, then per-request tweaks — rather than all in one expression.

Step 3 — Draw the line between what varies and what stays fixed. The line here is unusual, because it is not about polymorphism at all.

What varieswhich subset of parts is supplied, and in what order they become known
What stays fixedthe finished object's invariants — the rules a valid instance must satisfy

That framing tells you the design straight away. If the timing of when you know each part varies, but the rules the final object must obey are fixed, then you need a place to accumulate parts (the builder) and a single moment where the rules are enforced (the build() call).

Step 4 — Decide when the choice is made. At runtime, accumulating parts, with an explicit finish. The advanced variant (section 5.3) pushes some of this to compile time, so that forgetting a required step fails tsc.

Step 5 — Name the pattern and say what it costs. The name is Builder. The costs are these. You now maintain two shapes — the builder and the product — and they have to be kept in sync. The object's construction is no longer a single expression, so answering "where did this get its port?" can require tracing a chain of calls. And a builder is a mutable object, which introduces aliasing bugs that a plain constructor never had (section 10.2). If the object has three obvious fields, you have paid all of that for nothing.

3. The mental model

In one sentence: a builder is a shopping basket with a checkout. You add named items in any order, and the object is created only at checkout, and only if the basket is legal.

The analogy that makes it stick — the sandwich counter. You do not hand the person behind the counter nine ingredients in a fixed order and hope for the best. You say "wheat bread… no cheese… extra pickles". Each instruction names itself, so order does not matter. Omissions take a default. And you never receive a half-made sandwich. If you ask for something impossible, like "gluten-free flatbread on the wheat roll", you are told at the counter, not after you have paid.

The three properties every good builder has — memorise these three, because they are the answer to "what makes a builder good?" in any interview:

  1. Named steps with defaults in one place — this gives you readability, and it removes the null placeholders.
  2. A single finish line where cross-field rules runbuild() is the only place that has the complete picture, so it is the only honest place to check the rules.
  3. No partially-built product ever escapes — the product is born lawful or not at all, which is 9.2.1's validate-at-birth rule scaled up to many fields.

When to reach for it. The signals are: "the constructor takes nine arguments and half are optional" · "we set some fields here and the rest over there, then call init()" · "you must call setX() before start()". That last one is the strongest signal of all. A documented order in which methods must be called is an invariant with no home, and Builder gives it one.

The honesty check you must apply first, which is the senior move. In TypeScript, an options object with destructured defaults already gives you property 1 for free:

typescript
const server = new HttpServer({ port: 443, tls: certs, compress: ["gzip", "br"] });

That is named, order-free, compiler-checked, and adding a new field breaks no existing caller. So Builder has to earn the extra machinery by giving you something an options object cannot: assembly staged across time, cross-field validation with good error messages, conditional assembly that reads better than object spreads, or compile-time enforcement of required fields (section 5.3). Say this trade-off out loud in an interview without being asked. Knowing when not to build a builder is the signal that you understand it.

4. Structure

① named steps, any order.port(443).tls(certs).compress("gzip","br").timeout(30_000)omitted steps → defaults, oncethe builder (mutable)accumulates partsholds defaultsknows nothing final— not usable as the product —② build()cross-field validation runs here③ the productcomplete · immutableevery invariant satisfiedno setters, no init()invalid → throws; nothing escapesThe finish line is the pattern: one moment where everything is known, so one place can judge it.
Figure 3 — Accumulate, then judge. Steps are named and order-free (blue). The builder is a mutable scratch space that is deliberately not a usable product (amber). build() is the single validation point (red). And only a complete, immutable object leaves it (green).

The participants: the Builder (the interface of steps, often left out in modern code), the ConcreteBuilder (accumulates parts and knows how to produce one representation), the Product (the thing being built), and the Director (optional — a class that runs a fixed recipe of steps; see section 7).

5. The implementation

5.1 The fluent builder, line by line

typescript
class HttpServerBuilder {
  // (1) Defaults live HERE, exactly once — not scattered across call sites
  #host = "0.0.0.0";
  #port = 8080;
  #timeoutMs = 30_000;
  #tls?: TlsConfig;                       // absent means "no TLS", explicitly
  #compression: Encoding[] = [];
  #http2 = false;

  host(h: string)             { this.#host = h; return this; }        // (2) set + return this
  port(p: number)             { this.#port = p; return this; }
  tls(cfg: TlsConfig)         { this.#tls = cfg; return this; }
  timeout(ms: number)         { this.#timeoutMs = ms; return this; }
  compress(...e: Encoding[])  { this.#compression = e; return this; }
  http2(on = true)            { this.#http2 = on; return this; }

  build(): HttpServer {                                                // (3) the finish line
    const errors: string[] = [];
    if (this.#port < 1 || this.#port > 65_535) errors.push(`port ${this.#port} out of range`);
    if (this.#tls && this.#port === 80)  errors.push("TLS configured on port 80");   // (4)
    if (this.#http2 && !this.#tls)       errors.push("HTTP/2 requires TLS for browsers");
    if (this.#timeoutMs < 1_000)         errors.push("timeout below 1s will drop slow clients");
    if (errors.length) throw new ConfigError(errors);                  // (5) ALL errors at once

    return new HttpServer({                                            // (6) complete + immutable
      host: this.#host, port: this.#port, tls: this.#tls,
      timeoutMs: this.#timeoutMs, compression: [...this.#compression], http2: this.#http2,
    });
  }
}

const server = new HttpServerBuilder()
  .port(443).tls(certs).http2().compress("gzip", "br")
  .build();                                  // → reads as a sentence; throws if the set is illegal

Now the numbered lines.

(1) Defaults live in one place. Compare this with the constructor version, where every call site repeated null, true, false just to reach the one argument it cared about. Changing a default is now a single edit here, with no churn across call sites.

(2) return this is the entire fluency mechanism. Each step returns the builder, so the calls chain together. Note the return type: it is this, not HttpServerBuilder. Using the polymorphic this type keeps the chains working correctly if the builder is ever subclassed (3.7.6).

(3) build() is where the pattern actually lives. Everything before it is just bookkeeping. If your builder's build() is a bare return new Thing(this.fields) with no validation, ask yourself whether an options object would have done the job (section 3).

(4) Cross-field rules finally have a home. The rule tls && port === 80 cannot be expressed in a constructor parameter's type. It cannot be checked by a field setter, because a setter sees only its own field. And it is exactly the class of error that reaches production. build() is the first moment where everything is known at once, which is why it is the only honest place to judge the whole configuration.

(5) Collect all the errors, then throw once. Throwing on the first problem you find means a misconfigured deployment gets fixed one error per restart — an infuriating loop for whoever is on call. Accumulating the errors gives the operator the full list in a single message. This detail costs three lines and is the difference between a good builder and a great one.

(6) The product is complete and immutable. Notice the defensive copy [...this.#compression]. Without it, the builder and the product share the same array, and a later .compress(…) call would mutate a server that had already been built (9.2.2's encapsulation rule). Missing this copy is the most common builder bug you will find in code review.

5.2 The immutable builder — safer chains

The builder above is mutable, which means that two chains derived from one builder secretly share state (section 10.2). If builders are going to be shared as templates, return a fresh builder from each step instead:

typescript
class RequestBuilder {
  private constructor(private readonly opts: Readonly<RequestOpts>) {}
  static create() { return new RequestBuilder({ method: "GET", headers: {}, timeoutMs: 5_000 }); }

  header(k: string, v: string) {
    return new RequestBuilder({ ...this.opts, headers: { ...this.opts.headers, [k]: v } });  
  }
  timeout(ms: number) { return new RequestBuilder({ ...this.opts, timeoutMs: ms }); }
  build(): Request { return new Request(validate(this.opts)); }
}

const base   = RequestBuilder.create().header("x-api-key", key);   // a reusable template
const listing = base.timeout(2_000).build();                        // ← base is untouched
const report  = base.timeout(60_000).build();                       // ← both are correct

Each step allocates a small object, and in exchange, a builder becomes a safe, shareable template. This is the spelling that well-designed HTTP client libraries use, and it is the one to reach for whenever a "base" configuration is reused in several places.

5.3 The type-state builder — required fields enforced by the compiler

This is the strongest form, and the one that impresses in interviews: make build() not exist until the required steps have been called. Generics track which fields have been set (3.7.4).

typescript
type Filled = { host: true; port: true };                        // the required set

class ServerBuilder<S extends Partial<Filled> = {}> {            // (1) S = what has been set
  private constructor(private readonly o: Partial<ServerOpts>) {}
  static start() { return new ServerBuilder<{}>({}); }

  host(h: string): ServerBuilder<S & { host: true }> {           // (2) the type grows per step
    return new ServerBuilder({ ...this.o, host: h });
  }
  port(p: number): ServerBuilder<S & { port: true }> {
    return new ServerBuilder({ ...this.o, port: p });
  }

  build(this: ServerBuilder<Filled>): Server {                   // (3) the `this` constraint!
    return new Server(this.o as ServerOpts);
  }
}

ServerBuilder.start().host("0.0.0.0").build();
// ✗ Error: The 'this' context of type 'ServerBuilder<{host: true}>' is not
//   assignable to method's 'this' of type 'ServerBuilder<Filled>'.

ServerBuilder.start().host("0.0.0.0").port(443).build();   // ✓ compiles

(1) The type parameter S is a phantom record of which steps have run. It holds no runtime data at all — only compile-time knowledge.

(2) Each step returns a builder whose type is the old one intersected with the fact it just learned.

(3) The this parameter is the trick. Declaring build(this: ServerBuilder<Filled>) means the method can only be called when the builder's type says every required field is present. A forgotten .port() is now a compile error with a readable message, not a runtime undefined.

Use it when a library is consumed by many teams and a misconfiguration is expensive — SDKs, infrastructure clients. Skip it when the audience is your own module. There, the type gymnastics cost more than a runtime check, and 9.4.1 section 6's rule about heavyweight spellings applies to types just as much as to classes.

5.4 Python

python
from dataclasses import dataclass, replace

@dataclass(frozen=True)                       # frozen → the product is immutable
class ServerOpts:
    host: str = "0.0.0.0"
    port: int = 8080
    tls: TlsConfig | None = None
    timeout_ms: int = 30_000

class ServerBuilder:
    def __init__(self, opts: ServerOpts | None = None): self._o = opts or ServerOpts()
    def port(self, p): return ServerBuilder(replace(self._o, port=p))     # immutable step
    def tls(self, cfg): return ServerBuilder(replace(self._o, tls=cfg))
    def build(self) -> Server:
        errs = []
        if self._o.tls and self._o.port == 80: errs.append("TLS on port 80")
        if errs: raise ConfigError(errs)
        return Server(self._o)

Python's dataclasses.replace gives you immutable steps almost for free. And note that for simple cases, a frozen dataclass with keyword arguments already covers the options-object honesty check, so builders in Python are reserved for construction that is genuinely staged or validated.

6. Five domains, the same shape

(a) Query builders — the pattern's most-used instance.

typescript
const rows = await db.select("id", "email")
  .from("users")
  .where({ active: true })
  .whereIn("plan", ["pro", "team"])
  .orderBy("created_at", "desc")
  .limit(50);                       // ← execution replaces an explicit build()

Two lessons hide in here. First, the finish line can be implicit: awaiting the builder triggers compilation to SQL. Second, this builder produces a different representation than the steps suggest — method calls go in, and a SQL string plus a parameter array come out. That is precisely the Gang of Four's "same construction process, different representations": the same chain of calls can emit a Postgres or a MySQL dialect.

(b) Test-data builders — the quiet killer app.

typescript
const user = aUser().withPlan("pro").withOrders(3).build();

Why this matters more than it looks: fixtures built from object literals have to specify every required field, so every test states twenty irrelevant details, and adding a required field breaks 300 tests at once. A test-data builder gives valid defaults for everything and lets each test name only what it is actually testing. That is a direct readability gain — the test now reads as its own intent — and a maintenance gain, because a new required field is one edit in the builder.

(c) Staged configuration across modules — the case options objects cannot cover.

typescript
const b = new AppConfigBuilder();
loadDefaults(b);                       // framework defaults
applyEnv(b, process.env);              // environment overrides
applyCliFlags(b, argv);                // operator overrides, highest priority
if (isProd) b.tls(await fetchCerts()); // conditional, async, order-dependent
export const config = b.build();       // one validation with everything known

Three separate modules contribute parts, in a defined order of precedence, and one of them needs to do I/O. An options object cannot express this without either mutation or a merge function that has to re-implement the precedence rules. And the single build() still validates the combined result, catching things like "the environment enabled TLS but the CLI overrode the port to 80".

(d) Message and document construction. A MIME email (headers, body parts, attachments, alternative representations), a PDF, or a protocol frame all have "many optional parts plus structural rules". MailBuilder().to(x).cc(y).attach(f).html(h).text(t).build() validates the rules — at least one recipient, text recommended when html is present, total attachment size under the provider's limit — at the one moment when they are all knowable.

(e) UI and animation chains. d3.select("svg").append("rect").attr("x", 10).attr("width", 40) and similar chaining APIs are builders whose product is a DOM subtree. Same mechanism (return this), same benefit (named steps, order-free), and the same caveat: the mutable-aliasing risk of section 10.2 is why some libraries return new selections instead.

7. Variants, including the Director

VariantShapeWhen
Fluent (mutable)steps return thisthe default; single-use builders
Immutableeach step returns a new builderbuilders shared as templates
Type-stategenerics plus a this parameter gate build()SDKs where misconfiguration is expensive
With a Directora class running a fixed recipe of stepsthe same recipe is applied to different builders
Nested / hierarchical.address(a => a.city("Pune").pin("411001"))the product has sub-objects with their own rules
Generateddecorators or codegen produce the builderlarge sets of DTOs where hand-writing is churn

The Director, explained properly. Most tutorials show it and no modern code uses it, which is confusing until you see its actual tension. A Director holds a recipe: "a minimal server is host plus port plus timeout; a hardened server adds TLS, HSTS, and rate limits." The recipe is the reusable part; the builder is the thing the recipe drives.

typescript
class ServerRecipes {
  static hardened(b: HttpServerBuilder, certs: TlsConfig) {   // the Director's method
    return b.tls(certs).http2().timeout(15_000).compress("br", "gzip");
  }
}
const prod = ServerRecipes.hardened(new HttpServerBuilder().port(443), certs).build();

It earns its keep only when the same recipe drives more than one builder — the Gang of Four's motivating case was one document-construction recipe that could emit HTML or plain text through different builders. With one builder, the Director is just a function, and writing it as a function, as above, is the honest modern spelling.

8. Where you already use it

What you have usedHow it builds up
new URL(...) then url.searchParams.append(...)a web address assembled one piece at a time
A query builder like db.select(...).where(...).limit(10)each call adds a clause; the query runs at the end
Java's StringBuilderappend many times, then toString() to finish
A test helper such as aUser().withNoAddress().build()sensible defaults, and you name only what matters

The URL example is one you can run in a browser console right now. You start with a base address, then add query parameters one call at a time — append("page", "2"), append("sort", "price") — and the object handles the fiddly parts for you: putting a ? before the first parameter, an & before the rest, and escaping any character that is not allowed in an address. You never assemble the string by hand, so you never produce a broken one.

That is what the pattern buys in every row: the pieces arrive one at a time, in whatever order suits the caller, and the rules about how they fit together live in one place rather than at every call site.

9. Ways to get it wrong

  1. A builder for three obvious fields. new PointBuilder().x(1).y(2).build() is ceremony; new Point(1, 2) is the design. The trigger to check: fewer than about four parameters, none optional, no cross-field rules means no builder.

  2. Mutable-builder aliasing. const b = base(); const a = b.port(1).build(); const c = b.port(2).build(); — with a mutable builder, b was mutated by the first chain, so the two products are not what the code appears to say.

    The fix: single-use builders by convention, or the immutable variant (section 5.2).

  3. A build() that does not validate. A builder whose finish line is a bare new Thing(fields) has bought naming but skipped the invariant win — often a sign that an options object was the right answer.

  4. A mutable product. If the built object still has setters, the "born lawful" guarantee lasts exactly one statement. Freeze the product, and put changes behind a toBuilder() round-trip.

  5. Shared sub-objects. Returning the product with the builder's own array, map, or Date references.

    The fix: copy them on build(), as in section 5.1 note (6).

  6. Required fields discovered at runtime. build() throwing "host is required" is acceptable; the type-state variant (section 5.3) makes it a compile error in libraries where that matters.

  7. Duplicated defaults. Defaults that live both in the builder and in the product's constructor will drift apart. Keep exactly one home for them (the builder) and let the product's constructor demand a complete options object.

  8. Fluent everything. Chaining is not a virtue in itself. A fluent API on an object with no assembly problem (user.setName("x").setAge(3)) makes mutation look elegant and makes invariants harder to enforce.

10. Builder compared with its neighbours

Compared withThe differenceChoose Builder when
Options objectoptions give naming and defaults for free; Builder adds staging, validation, and type-stateassembly spans time or modules, or cross-field rules need one home
Factory Methoda factory answers which class; a Builder answers how to assemble onethe class is known and the assembly is complex
Abstract Factoryfamilies of related objects versus one complex objectthe complexity is in one object, not the set
PrototypePrototype copies a configured example; Builder assembles from partsthere is no example, or every field is genuinely chosen
Fluent interfacefluency is a syntax; Builder is fluency plus a finish line and invariantsyou need build() to actually mean something
CompositeComposite is the resulting tree; Builder is how the tree is assembledthey combine: builders often produce composites

The most useful boundary to say out loud: Factory chooses the class, Builder configures the instance, Prototype copies an existing one. Those three sentences separate the creational patterns cleanly, and they are worth having word for word.

11. Interview calibration

The 45-second answer, in the order you would say it:

Builder is for objects with many parts — mostly optional, sometimes tied to each other — where a constructor becomes unreadable and has nowhere to put cross-field rules. It gives named steps with defaults in one place, a single build() where all the invariants are checked with the complete picture in view, and a guarantee that no half-built object escapes.

In TypeScript I always apply the honesty check first: an options object with defaults already gives naming and order-independence, so Builder has to earn its keep with staged assembly across modules, rich cross-field validation, or type-state — using generics and a this parameter so that build() does not even exist until the required steps have been called. The cost is a second shape to maintain and a mutable object that can alias, so I copy collections into the product.

Follow-up questions, with the seed of each answer:

  • "Why not just setters?" — Setters make invariants uncheckable, because each one sees only its own field, and they leave the object mutable and partially valid between calls. Builder confines the invalidity to a scratch object that is never the product.
  • "Where does validation belong?" — At build(), accumulating all the errors and throwing once. Single-field rules can also live at the step for a faster signal.
  • "What is the Director?" — A reusable recipe of steps, useful when one recipe drives several builders; otherwise it is just a function.
  • "How do you make required fields safe?" — A runtime check in build() normally, and type-state in libraries where a misconfiguration is expensive.
  • "Have you seen builders misused?" — Three-field builders, a build() with no validation, and shared mutable builders — name the aliasing bug specifically.

Recall

  • Builder = named steps + one finish line + no half-built object. Its two enemies are the telescoping constructor (new Server("0.0.0.0", 8080, null, true, false, …) — positional ambiguity, unreadable, null used as a placeholder) and the homeless cross-field invariant (TLS on port 80; HTTP/2 without TLS) that no single parameter or setter can check.
  • The three properties everything rests on: defaults in one place · cross-field validation at build() with all errors accumulated and thrown once · the product emerges complete and immutable, with collections defensively copied so it cannot share state with the builder.
  • The honesty check comes first: an options object with destructured defaults already gives naming, order-independence, and backward-compatible additions. Builder must add staged assembly (parts arriving from different modules over time), rich validation, conditional assembly, or type-state — generics plus a this parameter so build() does not exist until the required steps have been called (3.7.4).
  • Variants: fluent and mutable (default) · immutable steps (for safe shared templates) · type-state (for SDKs) · the Director, which is a reusable recipe worth a class only when one recipe drives several builders · nested builders for sub-objects. You already use it: query builders (implicit finish at await, multiple SQL dialects — the Gang of Four's "different representations"), StringBuilder, URLSearchParams, and test-data builders (aUser().withPlan("pro").build()), the quiet killer app.
  • Misuse: three-field builders · a mutable builder shared between two chains · a build() that validates nothing · a mutable product · shared sub-object references · duplicated defaults in the builder and the constructor. The boundary sentence: Factory chooses the class, Builder configures the instance, Prototype copies an existing one.

Self-test: Name the three properties of a good builder. What can build() check that no setter and no parameter type can? When does an options object win, and what four things can it not do? What is the defensive-copy bug, and what test catches it? How does type-state make a forgotten required field a compile error?

Quiz Bank

FoundationalDerive Builder from a telescoping constructor and name what each part of the pattern fixes.

Naive: new HttpServer(host, port) — two clear parameters, correct.

The force: options accumulate (TLS, timeout, compression, HTTP/2, logging), most of them optional, and some of them acquire relationships — HTTP/2 requires TLS, TLS on port 80 is nonsense.

What breaks: the call becomes new HttpServer("0.0.0.0", 8080, null, true, false, 30_000, null, ["gzip"], true). That is positional ambiguity (two adjacent booleans can be swapped with no compiler complaint), null used as "not applicable" so intent is unrecoverable, a telescoping family of overloads as parameters get inserted, and no home for the cross-field rules, so each one is either unchecked or scattered into callers.

The varies/fixed line: what varies is which parts are supplied and when they become known; what is fixed is the finished object's invariants. That tells you to separate accumulation from judgment.

The pattern: named steps that carry their own meaning (fixes the ambiguity and the null placeholders), defaults in exactly one place (fixes call-site churn when a default changes), a single build() that sees everything (gives the cross-field rules a home, and lets you accumulate every error and report them together rather than one per restart), and a complete immutable product (nothing half-built escapes).

The cost: two shapes to maintain, construction that is no longer a single expression, and a mutable builder that can alias — which is why collections are copied into the product.

FoundationalWhen does an options object beat a Builder in TypeScript, and what exactly can a Builder do that an options object cannot?

Options objects win by default, and saying so is the senior move. new Server({ port: 443, tls }) is named, order-free, compiler-checked, and additive — a new optional field breaks no existing call site, which was the telescoping constructor's fatal flaw solved structurally. Destructured defaults ({ port = 8080, timeoutMs = 30_000 } = opts) put the defaults in one place too. So for a plain data payload, an options object is the design and a builder is ceremony.

Four things it cannot do. First, staged assembly: when parts arrive from different modules at different times — framework defaults, then environment, then CLI flags, then an async certificate fetch — an options object forces either mutation or a hand-written merge that re-implements precedence, whereas a builder is a natural accumulator with one final judgment over the combined result. Second, cross-field validation with a good error surface: a type can say port: number; it cannot say "not 80 when TLS is set", and a build() sees everything at once and can gather all the violations into a single actionable message. Third, conditional assembly that reads well: if (isProd) b.tls(certs).http2() versus nested spreads with conditional keys. Fourth, type-state: generics that track which steps have run, with build(this: Builder<Filled>) making a forgotten required field a compile error rather than a runtime throw — worth the complexity in an SDK consumed by many teams, not inside your own module.

The decision sentence: reach for a builder when construction has time or rules, not merely many fields.

AppliedExplain the mutable-builder aliasing bug with code, then give two fixes and when to use each.

The bug: builders are mutable objects, and return this means every chain off the same builder is the same object. const base = new RequestBuilder().header("x-key", k); const a = base.timeout(2_000).build(); const b = base.timeout(60_000).build(); — the second chain mutated the shared builder, so if any later code reuses base it now carries a sixty-second timeout it never asked for, and if the products kept references to builder-owned collections they would drift together. The same defect appears in a subtler place: build() returning { compression: this.#compression } without copying, so a later .compress("br") on the builder silently changes an already-built server — a mutation-through-alias bug that violates the product's "born complete and immutable" guarantee (9.2.1).

Fix one — defensive copies plus single-use discipline: spread every collection into the product ([...this.#compression], new Map(this.#headers)), and treat builders as single-use by convention. This is cheap, and it is sufficient for builders that are constructed and consumed in one expression, which is most of them.

Fix two — the immutable builder: every step returns a new builder carrying merged options (new RequestBuilder({ ...this.opts, timeoutMs: ms })). Now base is a genuinely safe, shareable template, and both chains are correct by construction. The cost is one small allocation per step, which is irrelevant at configuration scale.

When to use which: immutable whenever builders are stored, exported, reused as bases, or handed to other modules — the moment a builder has a name and a lifetime beyond one expression. Mutable-with-copies for the local, one-shot case.

The regression test that must exist either way: build a product, mutate the builder afterwards, and assert the product is unchanged.

InterviewShow how type-state makes a forgotten required field a compile error, and say when that complexity is justified.

The builder carries a phantom type parameter that records which steps have run: class ServerBuilder<S extends Partial<Filled> = {}> where type Filled = { host: true; port: true }. Each step returns the builder type intersected with the fact it just learned — host(h: string): ServerBuilder<S & { host: true }> — so the type grows along the chain even though nothing about it exists at runtime. The gate is a this parameter on the finish line: build(this: ServerBuilder<Filled>): Server. TypeScript checks the receiver's type against that declaration, so ServerBuilder.start().host("0.0.0.0").build() fails to compile with a message naming the mismatch, while adding .port(443) makes it compile (3.7.4).

Two refinements are worth mentioning. You can prevent duplicate steps the same way, by constraining host to be callable only when S lacks host. And you can brand the product type so that a Server cannot be forged around the builder.

When it is justified: libraries and SDKs consumed by many teams, where a misconfiguration is expensive and the feedback loop is otherwise a production incident — infrastructure clients, payment SDKs, anything whose failure mode is silent. When it is not: inside your own module, where a build() throwing HostRequiredError gives the same information one test run later at a fraction of the cognitive cost.

The general principle from 9.4.1 section 6 applies to type-level machinery exactly as it applies to classes: choose the cheapest spelling that makes the failure impossible in the context that matters — and the audience determines the context.

StaffYour platform team owns a TypeScript SDK used by 40 internal services. The API is new ApiClient(url, key, retries?, timeoutMs?, telemetry?, cache?, region?) and there are two recurring complaints: upgrades break call sites, and misconfiguration incidents (dev keys with prod URLs) reach production. Redesign the creation story and justify each choice against a complaint.

Both complaints are creation-design failures with distinct causes, and the redesign should address them separately rather than with one blanket "add a builder".

Complaint one — upgrade breakage — is the telescoping constructor. Every added parameter reshuffles positional call sites across 40 services, and inserting rather than appending is a breaking change. The fix: kill positional arguments. ApiClient.create(opts: ClientOptions) with destructured defaults makes additions purely additive — a new optional field cannot break an existing call — and this alone resolves complaint one for the majority of consumers who need nothing more. Ship it as the primary path; the honesty check (section 3) says most consumers should never see a builder at all.

Complaint two — misconfiguration reaching production — is the homeless cross-field invariant. Nothing validates key-against-URL-against-region consistency at assembly time, so a dev key with a prod URL is a well-typed, perfectly compiling incident. The fix: a builder for the genuinely staged path, because the SDK's reality is staged — platform configuration arrives from the environment, service configuration from code, and sometimes credentials from an async secret fetch. build() is the first moment with everything known: a dev-key-with-prod-URL becomes a construction-time ConfigError naming both fields, and all violations are reported together so a misconfigured deploy is fixed in one pass. This kills the incident class at startup rather than in traffic.

Three reinforcing moves. First, environment presets shaped as a family (9.4.3): ApiClient.forEnvironment("prod") returns a client whose telemetry exporter, cache policy, and credential source are mutually consistent — making the mixed-environment combination unrepresentable rather than merely validated, which is strictly stronger than a check. Second, type-state on the two invariants that actually matterbuild() absent until the service name and region are set — moving a class of upgrade-time misconfiguration from runtime to tsc across all 40 services at once (section 5.3). Third, a published test builderFakeApiClient.builder() — so that services stop hand-mocking the SDK; hand-rolled mocks are how consumer tests silently drift from real behaviour, and shipping the fake is how a platform team prevents that at scale.

The evolution policy, stated in the SDK's README: new options land as optional fields with defaults; deprecations ship typed @deprecated markers one release ahead of removal; the composition-root recipe (construct once, inject everywhere) is documented explicitly to head off the "static singleton client per service" misuse that would otherwise reappear forty times (9.4.6).

The measurable claim for the proposal: upgrade-related call-site edits go to zero for additive changes, and the misconfiguration class moves from "detected in production traffic" to "detected at process start or at compile time". Both complaints become structurally extinct rather than policed by review.

Flashcards

FlashBuilder's three properties

Named steps with defaults in one place · cross-field validation at build(), all errors at once · no half-built object escapes (product complete and immutable, collections copied).

FlashThe honesty check

Options object first — named, order-free, additive. Builder must add staged assembly, cross-field rules, conditional assembly, or type-state.

FlashTelescoping constructor

new Server("0.0.0.0", 8080, null, true, false, 30000, null, ["gzip"], true) — positional ambiguity, null as placeholder, no home for cross-field rules.

FlashType-state builder

A phantom generic records which steps ran; build(this: Builder<Filled>) makes a forgotten required field a compile error. For SDKs, not for your own module.

FlashThe aliasing bug

A mutable builder shared between two chains, or a product holding the builder's array. Fix: copy collections on build(), or immutable steps returning new builders. Test it.

FlashDirector

A reusable recipe of steps. Worth a class only when one recipe drives several builders; otherwise write it as a function.

Scenario Drill

DrillA team's integration tests are 4000 lines of object literals; adding a required field to the Order model broke 312 tests in one commit, and reviewers say tests are unreadable because each one specifies twenty irrelevant fields. Design the fix with builders, decide what defaults should be, and say how you would migrate without a 312-file pull request.

Diagnose precisely, because the fix follows from the cause. Two distinct problems wear one costume. Unreadability comes from a poor signal-to-noise ratio: a test about discount rules states twenty fields, nineteen of which are irrelevant, so the reader cannot tell which value is the subject. Fragility comes from duplicated construction knowledge: 312 literals each encode the full shape of Order, so a schema change is shotgun surgery (9.1) with the compiler as the only, very loud, guide.

The fix: a test-data builder per aggregate. anOrder() returns a builder pre-loaded with a valid, boring, representative order, and each test names only its subject: anOrder().withDiscount(percent(10)).withItems(2).build(). Now the test reads as its own intent, and a new required field is one edit in the builder rather than 312.

The default policy — the part teams get wrong. Defaults must be, first, valid — the product of anOrder().build() passes every domain invariant, so tests never accidentally exercise an impossible state. Second, boring — mid-range, unremarkable values, never zeros or empty strings, because those are exactly the edge cases a test should have to ask for. Third, deterministic — no Math.random() or Date.now(), since a randomly-passing test is worse than no test; use a seeded generator if variety is genuinely needed, and log the seed. Fourth, minimal — one item, no coupons, no partial shipments, so complexity is opt-in. Fifth, composed from other buildersanOrder() internally uses aCustomer() and aProduct(), so the defaults have one home per aggregate and the object graph stays consistent. Add explicit named scenarios for recurring shapes (aRefundedOrder(), aSubscriptionOrder()) as thin wrappers, which keeps intent legible without a soup of parameters.

Migration without a 312-file pull request — the sequencing is the answer. First, land the builders as pure addition alongside the literals; nothing changes, nothing breaks, one small reviewable PR. Second, adopt them in new tests only, enforced by a lint rule scoped to new files, so the codebase starts improving immediately with zero migration risk. Third, migrate by touch: any test edited for another reason converts to the builder in the same PR (the boy-scout rule with a mechanical trigger), which spreads the work across normal feature velocity instead of a freeze. Fourth, bulk-convert only the highest-churn files, identified from git log — typically ten to fifteen percent of files account for most future edits, so most of the benefit lands in a small, targeted batch that one reviewer can actually read.

Fifth, ratchet it: a CI check counting raw new Order({ occurrences that may only ever decrease, which makes the migration survive reprioritisation and staff turnover. Sixth, cut the recurrence at the root: the next required-field change should be a single builder edit — verify that claim deliberately by adding a field behind the builder and confirming the diff is one file.

What I would explicitly not do: a global codemod converting all 312 literals mechanically. It produces an unreviewable diff, and worse, it would faithfully translate each literal's twenty irrelevant fields into twenty .with…() calls — preserving the very noise the exercise exists to delete.

The sentence for the team: test data is production code with a different audience; the builder is how a test says only what it means, and how a schema change stops costing a day.