23.1 Builder Pattern with Chainable Methods and Accumulated Types
Alright, let’s talk about building things properly. You’ve probably seen the standard Builder Pattern: a nice, stateful object with a bunch of .setSomething() methods that culminate in a .build() call. It’s fine. It gets the job done. But it has a glaring, almost comical weakness: it doesn’t stop you from building complete nonsense.
What’s the point of a builder if it lets you call .build() before you’ve set the one field that is absolutely, non-negotiatably required? It’s like a factory that lets you drive a car off the line whether the steering wheel is attached or not. “Good luck, champ!” We can do so much better. We can build a builder that guarantees at compile time that you’ve provided all the necessary parts. This is where we combine chainable methods with the type system’s ability to accumulate knowledge about the object’s state.
The Core Idea: The Builder’s State as a Type Parameter
The trick is to make the builder’s type change as you call its methods. Instead of a single Builder class, we’ll have a Builder<HasThis, HasThat> where the type parameters are flags—often simple boolean-like types (true or false)—that track what’s been set.
We start with a builder that has nothing. Each method that sets a required field returns not the same builder, but a new builder type—one that has the same methods, but with the type parameter for that field now set to “true”. The .build() method is only available on the final type where all required parameters are marked as “true”.
// These are our state flags. We'll use them as type parameters.
interface BuilderState {
hasName?: boolean;
hasAge?: boolean;
}
class PersonBuilder<S extends BuilderState = {}> {
// The internal state is private, of course.
private state: { name?: string; age?: number } = {};
// The constructor might take initial state for a more advanced use case,
// but we'll start empty.
constructor(initialState?: { name?: string; age?: number }) {
if (initialState) this.state = { ...initialState };
}
setName(name: string): PersonBuilder<S & { hasName: true }> {
// We create a *new* builder instance. This is key.
// We spread the current state and add the new field.
const newBuilder = new PersonBuilder<S & { hasName: true }>({
...this.state,
name: name,
});
return newBuilder;
}
setAge(age: number): PersonBuilder<S & { hasAge: true }> {
const newBuilder = new PersonBuilder<S & { hasAge: true }>({
...this.state,
age: age,
});
return newBuilder;
}
// The magic: build() is only available if S includes both flags.
build(
this: PersonBuilder<S & { hasName: true; hasAge: true }>
): { name: string; age: number } {
// We know at this point that state.name and state.age are defined,
// but TypeScript can't infer that from the type guard alone.
// So we use a non-null assertion (!) because WE know it's safe.
return {
name: this.state.name!,
age: this.state.age!,
};
}
}
Now, watch how the intelligence unfolds:
const builder = new PersonBuilder(); // Type: PersonBuilder<{}>
const withName = builder.setName("Alice"); // Type: PersonBuilder<{ hasName: true }>
const withAge = withName.setAge(30); // Type: PersonBuilder<{ hasName: true; hasAge: true }>
const validPerson = withAge.build(); // Works perfectly!
const invalidAttempt = builder.setAge(30).build();
// ^^^^^^ Property 'build' does not exist on type 'PersonBuilder<{ hasAge: true }>'.
The compiler literally stops you from shooting yourself in the foot. It’s beautiful.
Why This Works: F-Bounded Polymorphism and The this Parameter
The secret sauce here is a concept called F-bounded polymorphism, which is a terrifying term for “a type that can refer to its own subclass.” We use it in the return types of setName and setAge: PersonBuilder<S & { hasName: true }>. This says, “return a PersonBuilder with the old state S and also this new piece of state.”
The this parameter in the build method is the other critical piece. this: PersonBuilder<S & { hasName: true; hasAge: true }> acts as a type guard. It tells TypeScript, “Only allow the .build() method to be called if the current instance’s type S is at least { hasName: true; hasAge: true }.” If that condition isn’t met, the method simply doesn’t exist on the object.
Handling Optional Fields and Method Chaining
The example above is tyrannical—everything is required. In the real world, you have optional fields. This is easy. You just add methods that return the same builder type, unchanged.
class PersonBuilder<S extends BuilderState = {}> {
// ... previous code ...
setHobby(hobby: string): PersonBuilder<S> { // Note: returns S, not S & something
this.state.hobby = hobby;
return this; // Returning 'this' is safe here because the type hasn't changed.
}
}
Now you can chain optional methods anywhere without affecting the required state:
const person = new PersonBuilder()
.setName("Bob")
.setHobby("Kite Flying") // Still a PersonBuilder<{ hasName: true }>
.setAge(25) // Now PersonBuilder<{ hasName: true; hasAge: true }>
.build(); // All good!
The Rough Edges and Pitfalls
This pattern is powerful, but it’s not all rainbows and unicorns.
Verbosity: The type definitions get gnarly fast. Your hover tooltips in VS Code will look like a generic explosion:
PersonBuilder<{ hasName: true; hasAge: true; } & { hasHobby: true }>. It’s correct, but ugly.The
private stateProblem: Did you notice we used a private field? This breaks down if you need to extend thePersonBuilderclass. A subclass won’t have access to the privatestateof the parent. The more robust approach is to make the state a protected generic parameter as well, but that increases the complexity tenfold. You often have to choose between elegance and extensibility.The “Reusing a Builder” Antipattern: Because each method call returns a new instance (or at least a new type), you cannot store an intermediate builder in a variable and reuse it to build multiple objects. The type system will track its state, and once you’ve called
.build(), that specific builder’s journey is over. This is by design, but it trips people up.
This pattern is a masterpiece of type-level programming. It moves what would be runtime validation errors (“Oops, forgot the name!”) into compile-time type errors, which is exactly where they belong. It makes your API not just user-friendly, but user-forceful. They have to use it correctly. And that, my friend, is the mark of a truly well-designed system.