In TypeScript, how do you type a fluent builder so the type of the object being built grows with each chained call, and what goes wrong if every chaining method returns the builder's own type unchanged?
answer
- knowledge must live in the return type
- one type parameter carries the accumulation
- each call returns a different instantiation
- intersect the new key into T
- same type returned means nothing accumulates
basics
~20 sMake the builder generic in what it has accumulated so far and have each chaining method return a new instantiation, such as Builder<T & Record<K, V>>, rather than Builder<T>. Returning the same type throws away whatever the call just added.
solid answer
~50 sThe only place the compiler can record what a chain has accumulated is the **type of the value each call returns**, so the accumulated shape has to live in a type parameter. I declare the builder as `Builder<T>` where `T` is the object built so far, and declare each chaining method as `add<K extends string, V>(key: K, value: V): Builder<T & Record<K, V>>` — a *different* instantiation of the same builder type, with the new key intersected in. Then `build(): T` returns exactly what was accumulated. If `add` returns `Builder<T>` instead, the chain still compiles and still runs, but `T` never grows, so `build()` hands back the empty starting shape and every property access on it is an error. The same happens with a method returning the builder's own instance type: chainability survives, accumulation does not.
code
typescript · 10 linestype Builder<T> = {
add<K extends string, V>(key: K, value: V): Builder<T & Record<K, V>>;
build(): T;
};
declare const empty: Builder<{}>;
const cfg = empty.add("url", "/api").add("retries", 3).build();
const url: string = cfg.url;
const retries: number = cfg.retries;go deeper
Know that a chained call returns a value, and the type of that returned value is what the next call and the final result are typed by. Be able to read Builder<T> as "builder for the shape built so far".
Explain that the accumulated shape must live in a type parameter, and write the method signature that intersects the new key in and returns a fresh instantiation. Say plainly why returning the unchanged builder type silently loses the information.
Show where the pattern strains in real code: opaque errors in long chains, duplicate keys collapsing to never, dynamic keys defeating it entirely, and the fact that the gate is erased so build() still validates.
Own the call on whether the extra typing complexity is worth it for a public API, and set the house rule for when a chain earns its place over a plain typed options object.
## What a builder has to express in the type system A fluent builder is a chain of calls: `b.add("url", "/api").add("retries", 3).build()`. Each call adds knowledge — after two calls, the thing being built is known to have two specific keys with two specific value types. TypeScript has exactly one place to store that knowledge: the type of the value each call returns. There is no hidden per-object state the checker can mutate, and nothing about the chain exists after compilation. So "the shape built so far" must be a **type parameter** that each call re-instantiates. ## The shape that works Declare the builder generic in the accumulated object type, and declare each chaining method to return a *different* instantiation that includes the new information: ```ts type Builder<T> = { add<K extends string, V>(key: K, value: V): Builder<T & Record<K, V>>; build(): T; }; declare const empty: Builder<{}>; const cfg = empty.add("url", "/api").add("retries", 3).build(); // cfg: {} & Record<"url", string> & Record<"retries", number> cfg.url; // string cfg.retries; // number ``` Two separate inferences happen at each call. `K` is inferred as the literal type `"url"` rather than widening to `string`, because a type parameter constrained by `string` preserves literal types during inference. `V` has no such constraint, so the literal `"/api"` widens to `string` — which is usually what you want for a config value; if you need the literal preserved you have to ask for it explicitly at the value level. The accumulated type is an intersection, which is why `build()` can return `T` directly: `{} & Record<"url", string> & Record<"retries", number>` behaves like `{ url: string; retries: number }` for every read. ## Why returning the same type silently loses everything ```ts type BadBuilder<T> = { add<K extends string, V>(key: K, value: V): BadBuilder<T>; build(): T; }; declare const empty: BadBuilder<{}>; const cfg = empty.add("url", "/api").build(); // cfg: {} cfg.url; // error: Property 'url' does not exist on type '{}' ``` Nothing here is a compile error at the *declaration* — `K` and `V` are simply inferred and discarded. The chain runs identically at runtime. The failure surfaces far away, at the first property access after `build()`, which is why this bug is so common in review: the builder "works", it just returns a type that knows nothing. Declaring the method to return the builder's own instance type has the same effect: it keeps you chainable across subclasses but cannot re-instantiate the type parameter, so `T` is frozen at whatever the chain started with. ## Where the pattern strains **Repeated keys.** Adding `"port"` twice with different value types produces `Record<"port", number> & Record<"port", string>`, and the property's type becomes the intersection of the two. For disjoint primitives that collapses to `never`, so the field is unreadable rather than "last write wins". You can forbid the repeat by constraining the key parameter to exclude keys already in `T`. **Dynamic chains.** The accumulation only works for chains written out statically. Build the same object in a `for` loop and the compiler sees one type parameter instantiated once with whatever it can infer from a loop variable; there is no type-level fold over runtime iterations. **Error messages.** A mistake in the middle of a long chain often reports at `build()` or at the next call, with a message full of nested intersections. Constraining each method's parameters tightly is what keeps the diagnostic on the offending call. **Erasure.** Type parameters do not exist after compilation. If you gate `build()` so it is only callable once required keys are present, that gate is a compile-time promise about statically written code. Anything reaching the builder through untyped input still needs a real runtime check inside `build()`. ## Gating completion The usual extension is to track *which* required keys are present, either as the accumulated object type or as a union of key names, and to make `build()` available only on the instantiation that contains all of them. That turns "you forgot to set `url`" into a compile error instead of a runtime one — with the same caveat that the check is erased, so it protects your callers' source, not your process.
- What happens if the caller adds the same key twice in one chain?The accumulated type intersects both entries, so the property's type becomes the intersection of the two value types — for disjoint primitives such as `string` and `number` that reduces to `never`, making the field unreadable rather than "last one wins". If you want to forbid it outright, constrain the key parameter to exclude keys already present in the accumulated type, so the error lands on the duplicate call.
- Does this typing survive if I build the object in a loop instead of a static chain?No. The accumulation is a compile-time fold over calls the compiler can see, so it only works for a chain written out in source. Inside a loop the compiler instantiates the method once against a loop variable and infers whatever wide type that variable has. If keys are dynamic, drop the accumulating type and validate the finished object at runtime instead.
saying these in an interview costs you the question
- Says returning the same builder type still keeps the added key
- Adds K and V to a method but never uses them in the return type
- Thinks the accumulated key list exists at runtime
- Claims duplicate keys resolve to the last value's type
- Believes build() must be cast to get the right type