Lesson 54 of 80
A fluent builder whose generic type parameter accumulates which fields have been set so far — so forgetting a required field is a compile error at .build(), not a runtime crash.
16 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
A normal builder's .build() method has a problem: it either requires every field to already be optional on the target type (weakening the type you actually want), or it can't know at compile time whether you've called every required .set*() method yet. The fix is to make the builder's own type change with every call, tracking which fields have been provided so far as a generic type parameter — and only expose .build() once that accumulated type proves every required field is present.
interface Pizza {
size: "small" | "medium" | "large";
crust: "thin" | "thick";
toppings: string[];
}
class PizzaBuilder<Built extends Partial<Pizza> = {}> {
private data: Built;
constructor(data: Built) {
this.data = data;
}
size(size: Pizza["size"]): PizzaBuilder<Built & { size: Pizza["size"] }> {
return new PizzaBuilder({ ...this.data, size });
}
crust(crust: Pizza["crust"]): PizzaBuilder<Built & { crust: Pizza["crust"] }> {
return new PizzaBuilder({ ...this.data, crust });
}
toppings(toppings: string[]): PizzaBuilder<Built & { toppings: string[] }> {
return new PizzaBuilder({ ...this.data, toppings });
}
build(this: PizzaBuilder<Pizza>): Pizza {
return this.data;
}
}
Each fluent method doesn't return this — it returns a new builder whose generic parameter is the old accumulated type intersected with the new field. Built & { size: Pizza["size"] } means "everything already set, plus size now too." The type parameter grows, call by call, into an ever-more-complete picture of what's been provided.
const pizza = new PizzaBuilder({})
.size("large")
.crust("thin")
.toppings(["olives"])
.build(); // ✅ Built is now { size, crust, toppings } — matches Pizza exactly
const incomplete = new PizzaBuilder({})
.size("large")
.crust("thin")
.build();
// ❌ Property 'this' is incompatible — Built is { size, crust }, missing 'toppings'
The trick living in build(this: PizzaBuilder<Pizza>): Pizza is TypeScript's this parameter — a way to constrain which specific instantiation of the class a method is even callable on. build isn't callable on any PizzaBuilder<Built>; it's only callable on one whose Built is assignable to the full Pizza type. Calling it before every field is set means this's actual type (say, PizzaBuilder<{ size, crust }>) doesn't satisfy PizzaBuilder<Pizza>, and TypeScript rejects the call — at the .build() call site, not buried somewhere at runtime.
this. Returning this from .size() would keep the same runtime object but couldn't actually change its own generic type parameter after construction — TypeScript's type system has no mechanism for a value's static type to mutate in place, so a new object carrying the new type is required.this parameter trick has nothing to do with this at runtime. build(this: PizzaBuilder<Pizza>) never receives an actual argument for this — it's purely a compile-time constraint on which instantiations of the class the method is callable on. Omit it, and build() becomes callable on an incomplete builder, silently returning an object with missing fields typed as Pizza anyway.Built types side by side (or hovering the failing call) is usually the fastest way to see the actual gap, which is a real, known ergonomic cost of the pattern worth weighing against its safety."How would you design a builder API where forgetting a required field is a compile error, not a runtime one?" is a strong systems-design-flavored TypeScript question — the answer requires combining generics, intersection types, and the this parameter constraint, which is exactly the kind of multi-feature synthesis that separates "knows the syntax" from "can design with it."