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?
answer
- a value cannot change its own type parameter
- the compiler needs a promise here
- one conversion, at the boundary
- untyped impl, precise public interface
- erased gate still needs a runtime check
basics
~20 sA 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.
solid answer
~60 s`return this` will not type-check: `this` is the old instantiation and the declared return type is a different one, and no amount of inference bridges them — so somewhere an assertion has to say "trust me". The discipline is to have exactly one. I implement the builder in a loosely typed class whose methods take `string` and `unknown`, then export a factory function whose *declared* return type is the precise generic interface and whose body contains the single `as unknown as` conversion. Callers get full accumulation, the unchecked seam is one line covered by tests, and no assertion leaks into user code. Two things to watch: the assertion is unverified, so a mistake in the interface is invisible to every caller; and the accumulated type is erased, so if `build()` gates on required fields it still needs a real runtime check for input that did not come through the typed chain. Also decide mutation up front — returning the same mutable `this` makes a branched chain share state at runtime while the types imply two independent builders.
code
typescript · 19 linesinterface Builder<T> {
set<K extends string, V>(key: K, value: V): Builder<T & Record<K, V>>;
build(): T;
}
class BuilderImpl {
constructor(private readonly parts: Record<string, unknown> = {}) {}
set(key: string, value: unknown): BuilderImpl {
return new BuilderImpl({ ...this.parts, [key]: value });
}
build(): Record<string, unknown> {
return this.parts;
}
}
export const builder = (): Builder<{}> =>
new BuilderImpl() as unknown as Builder<{}>;go deeper
Know that a type assertion tells the compiler to accept a type without checking anything, and that it disappears from the emitted JavaScript. Be able to spot one in a builder implementation.
Explain why a value cannot change its own type parameter, so returning the same object as a different instantiation requires an assertion, and where the assertion belongs — the factory boundary, not every method.
Show that you localise the unchecked seam, pin it with type-level and runtime tests, still validate in build() because the gate is erased, and decide copy-on-write versus mutation before callers start branching chains.
Own the policy for unchecked seams in shared libraries: where assertions are permitted, what test evidence a reviewer must see beside one, and whether an accumulating builder's complexity is worth carrying in the public surface at all.
## Why an assertion is unavoidable An accumulating builder declares each chaining method to return a different instantiation of the builder type: ```ts interface Builder<T> { set<K extends string, V>(key: K, value: V): Builder<T & Record<K, V>>; build(): T; } ``` At runtime there is only one object (or a fresh copy of one), and it is the same class either way. But in the type layer, `Builder<T>` and `Builder<T & Record<K, V>>` are two different types, and a value cannot re-declare its own type parameter mid-life. `return this` fails to check for exactly that reason, and there is no sound conversion the compiler could invent: the object genuinely does not yet have the property the new type promises — the *next* line of the method is what puts it there. So the pattern requires an assertion. That is not a defect in your code; it is the seam where a compile-time story about a chain meets a runtime object that just carries a bag of values. ## Put the seam in exactly one place The bad version sprinkles `as` through every chaining method, so each one is separately capable of lying. The good version keeps the implementation deliberately untyped inside and asserts once, at the factory that hands the builder to callers: ```ts interface Builder<T> { set<K extends string, V>(key: K, value: V): Builder<T & Record<K, V>>; build(): T; } class BuilderImpl { constructor(private readonly parts: Record<string, unknown> = {}) {} set(key: string, value: unknown): BuilderImpl { return new BuilderImpl({ ...this.parts, [key]: value }); } build(): Record<string, unknown> { return this.parts; } } export const builder = (): Builder<{}> => new BuilderImpl() as unknown as Builder<{}>; ``` The class knows nothing about accumulation — it copies a record and returns a new instance. The public type knows everything about accumulation and nothing about the class. One conversion joins them, and because `BuilderImpl` and `Builder<{}>` are not comparable in either direction (the private field alone makes the class nominal), the conversion has to route through `unknown`, which is a useful signal rather than a nuisance: it marks the line as the unchecked one. ## The assertion is unverified — treat it as such An assertion performs no check and emits no code. If the declared interface says `build(): T` while the implementation forgets to store a value, no caller finds out from the compiler; they get `undefined` typed as `string`. Two habits keep the seam honest. First, cover the *types* with type-level tests: write a file of chains that must compile, plus chains that must not, marked with `@ts-expect-error` so the build fails if the error ever stops appearing. Second, cover the *values* with an ordinary unit test that builds an object and asserts its contents. The type test protects the interface, the unit test protects the class, and the assertion in between is only as trustworthy as both. ## Erasure: the gate is a source-code promise If you extend the interface so `build()` is only available once required keys are present, you have added a compile-time gate over statically written chains. Nothing survives to runtime — the type arguments are erased, there is no reflection over them, and a caller reaching the builder from untyped JavaScript, from `JSON.parse` output, or through `any` can call `build()` on an empty builder. A public builder should therefore still check its own invariants in `build()` and throw a clear error. The type layer removes the mistake from code the compiler sees; it does not remove it from the process. ## Mutation is a design decision, not an afterthought Because the chain reads as functional, callers assume each call yields an independent builder. If your `set` mutates and returns `this`, that assumption is false: ```ts const base = builder().set("host", "localhost"); const a = base.set("port", 80); const b = base.set("port", 443); // with a mutating impl, a and b are the same object: both see 443 ``` The types say `a` and `b` have different accumulated shapes; the runtime says they are one object. Copy-on-write — construct a new instance from a spread of the previous parts, as in the example above — makes the runtime match the types, at the cost of one small object per call, which is almost always the right trade for a configuration builder. If you deliberately keep it mutating for performance, say so in the API docs and do not present branched chains as supported.
- How do you keep that single assertion from silently going wrong?Cover it from both sides. Write type-level tests — a file of chains that must compile plus chains marked `@ts-expect-error` that must not, so the build fails if a guard ever stops firing — and ordinary unit tests that build real objects and assert their contents. The assertion sits between an interface and a class, and it is only trustworthy while both are pinned.
- Would declaring the method to return the builder's own instance type avoid the assertion?It avoids the assertion but loses the point. That form keeps the chain returning whatever concrete type it started on, which is useful for subclassable fluent APIs, but it cannot re-instantiate the generic parameter — so nothing accumulates, and `build()` returns the shape you began with. If you want accumulation, you are choosing the assertion; if you want subclass-friendly chaining without accumulation, you are choosing the instance type.
- What breaks if a caller reaches build() without setting the required fields?Nothing at compile time if they came through untyped code — the gate is erased, so `build()` is just a method on an object. A public builder should validate its own invariants inside `build()` and throw a message naming the missing fields. The type-level gate is a fast feedback loop for typed callers, not an enforcement mechanism.
saying these in an interview costs you the question
- Says `return this` type-checks when the return type differs
- Puts an assertion in every chaining method
- Assumes the compile-time key tracking exists at runtime
- Reuses one mutable object and calls the chain immutable
- Types the internals as any and never re-narrows at the boundary