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?
answer
- is there a dependency between steps?
- one type versus an incremental contract
- selected columns typing the returned row
- errors land at build(), not the mistake
- the chain shape becomes public API
basics
~20 sDefault 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.
solid answer
~50 sMy default is a single options object with a precise type, optionally taken through a small generic identity helper so the caller's literal shape is preserved. It gives one error site, it composes with spreads and partial config merging, it serializes, and adding an optional field is a non-event. I move to an accumulating builder only when the API has a real **dependency between steps** — a query builder where `.select(...)` decides the row type of the result, or an ordering constraint the type system should enforce. The costs are concrete: diagnostics degrade into nested intersections that often surface at the wrong call; long chains cost real editor latency; the completeness gate is erased, so `build()` still validates; and the chain's *type* becomes public API, so adding a required step is a breaking change with an opaque message. If I ship one, I constrain each method tightly so errors land on the offending call, and I ship type-level tests alongside the runtime ones.
go deeper
Know both shapes exist: a single options object passed once, and a chain of calls ending in build(). Be able to say that the object is the simpler default for flat configuration.
Explain the mechanical difference — one type checked once versus an incremental contract where each call can change what the next may do — and name a case where a later step's type depends on an earlier call.
Weigh the real costs you have felt: errors landing at build() instead of the mistake, editor latency in long chains, the erased gate needing a runtime check, and type-level tests as part of the deliverable.
Set the house rule and own its consequences: when a chain is justified, how the chain shape is versioned, what a breaking step change costs downstream, and what evidence a reviewer must see before a builder enters the public surface.
## Frame the decision as "is there a dependency between steps?" Both shapes describe the same runtime thing: a bag of settings. The type-level difference is that an options object is checked **once**, against one declared type, while a builder is checked **incrementally**, with each call able to change what the following calls are allowed to do and what the final result is typed as. That extra power is the only thing worth paying for. So the question is not "which is more elegant" but: does any later part of this API depend on an earlier choice in a way a single object cannot express? If the answer is no — most configuration is a flat record of independent settings — the options object wins on every axis that matters afterwards. ## What an options object buys One type is one place to read, one place to document, and one place errors point at. Excess or misspelled keys are reported on the literal, next to the mistake. The object composes: callers can spread defaults, merge environment-specific overrides, or store the config in a JSON file and load it. It survives serialization, logging, and diffing, none of which a half-built chain does. Adding an optional field is additive and invisible to existing callers. When you want the caller's literal shape preserved for later inference, a small generic identity helper does it without a chain: ```ts type Config = { plugins?: string[]; strict?: boolean }; function defineConfig<T extends Config>(config: T): T { return config; } ``` That is a factory in the sense this topic cares about — the return type is precisely what the caller wrote — with none of a builder's machinery. ## What the builder actually buys A builder earns its keep when the type of a later step depends on an earlier one. The canonical case is a query builder: `.select("id", "name")` should make `.execute()` return `{ id: ...; name: ... }[]`, not a wide row type. An options object cannot express that without the caller restating the shape by hand. Ordering constraints are the other genuine case — an API where some step must come before another, and you would like that to be a compile error rather than a runtime one. A third, weaker case is assembly across modules, where different files each contribute part of a config and you want the accumulated type to travel with the value. ## The bill **Diagnostics.** Accumulated types are intersections that grow with the chain. When something goes wrong, the message shows the whole accumulated shape, and the error frequently lands on `build()` or on the *next* call rather than the mistaken one. This is the single biggest complaint users have about builder APIs, and it is mitigated — not removed — by constraining each method's parameters tightly so the failure has nowhere to defer to. **Editor cost.** Every call instantiates a fresh generic type. Long chains, especially with nested conditional or mapped machinery behind them, show up as completion latency in real projects. Users experience this as "the library is slow", and the cause is invisible to them. **Erasure.** A builder that refuses to compile `build()` until required steps are done has protected typed source only. Untyped callers, or data arriving from outside, walk straight past it, so the runtime check still has to exist. Two implementations of the same invariant is two things to keep in sync. **Evolution.** With an options object, the public contract is one type; adding an optional key is trivially compatible. With a builder, the *chain* is the contract. Adding a required step breaks every existing chain, and it breaks it at `build()` with a message about intersections rather than "you must now call `.region()`". Renaming a method is worse. Plan on versioning the entry factory rather than mutating the interface in place. **Test surface.** A builder's value is its types, so its tests must include type-level tests — chains that must compile and chains marked `@ts-expect-error` that must not. Runtime tests alone certify nothing about the feature you shipped. ## A defensible house position Options object by default; a builder only where a step dependency exists; and where a builder ships, keep the chain shallow, constrain each method so errors land locally, validate in `build()` regardless, and treat the chain shape as versioned public API. If a reviewer cannot say in one sentence which later step depends on which earlier one, the builder is decoration and should be an object.
- Give a case where the builder genuinely wins.A query builder. `.select("id", "name")` should make `.execute()` return rows typed `{ id: ...; name: ... }`, and `.where(...)` should only accept columns that exist on the table chosen earlier. Both are dependencies between steps: the type of a later call is determined by an earlier one. A single options object cannot express that without the caller restating the row shape by hand, which defeats the point.
- If you do ship a builder, how do you keep the error messages usable?Constrain every method's parameters as tightly as possible so a mistake has nowhere to defer to — a key parameter restricted to the keys still allowed fails on the offending call, whereas a permissive parameter pushes the failure to `build()`. Keep chains shallow, avoid stacking heavy type machinery behind each step, and ship type-level tests so a regression in diagnostics is caught rather than reported by users.
- What is your deprecation story when a builder step has to change?Treat the chain shape as public API. Adding an optional step is safe; adding a required one breaks every existing chain and fails at `build()` with an unhelpful message, so it warrants a major version and a new entry factory rather than an in-place edit. Keep the old factory working for a release with a runtime deprecation warning, and give the compile error a hand-written message where the type system allows it.
saying these in an interview costs you the question
- Picks a builder because chaining looks nicer
- Claims builder errors read as well as object-literal errors
- Assumes the required-step gate holds at runtime
- Treats adding a required builder step as non-breaking
- Ignores editor latency from long generic chains