skip to content

Generic Builders & Factories

Typing APIs where each call refines the type of the thing being built — a fluent builder, a schema/config factory, or a Result wrapper. The hard part is keeping inference alive across the chain instead of collapsing to a wide type.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

4

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?

level: middleimportance: should knowfreq 40%

answer

  1. knowledge must live in the return type
  2. one type parameter carries the accumulation
  3. each call returns a different instantiation
  4. intersect the new key into T
  5. same type returned means nothing accumulates

basics

~20 s

Make 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 s

The 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 lines
typescript
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();
const url: string = cfg.url;
const retries: number = cfg.retries;

go deeper

for a junior

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".

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

How do you type the `ok()` and `err()` constructor helpers of a generic `Result<T, E>` union so a function can return both without annotating T and E at every call site?

level: middleimportance: should knowfreq 45%

basics

~20 s

Give each helper a type parameter only for the branch it actually fills: ok<T>(value: T): Result<T, never> and err<E>(error: E): Result<never, E>. Because never is assignable to anything, both results fit the function's declared Result type.

open as a page

A TypeScript builder's `.set()` is declared to return `Builder<T & Record<K, V>>`, but at runtime it hands back the same object. What does that force the implementation to do, and what should you watch for?

level: seniorimportance: should knowfreq 30%

basics

~20 s

A value cannot change its own type parameter, so the implementation needs a type assertion to hand the object back as the new instantiation. Keep that assertion at one boundary, and remember the accumulated type is erased, so build() still validates at runtime.

open as a page

You are designing the configuration API of a TypeScript library. How do you decide between a type-accumulating fluent builder and a single typed options object, and what does the builder cost your users?

level: principalimportance: nice to knowfreq 20%

basics

~20 s

Default to a typed options object: one type, one error location, easy to serialize and extend. Reach for an accumulating builder only when later calls must depend on earlier ones — and accept worse diagnostics, slower editors, and a harder deprecation story.

open as a page