Builder — Staged Construction Expressed with Object Literals and Types
This chapter covers when a literal is enough and when a builder that expresses staged requirements in the type earns its keep.
The problem this pattern set out to solve
GoF catalogs Builder as a pattern that separates the construction of a complex object from its representation1. The original aim was to let the same procedure assemble different representations.
When the name gets used in practice, it usually leans on a different motivation: wanting to do something about a constructor with too many arguments.
new CoffeeOrder('ethiopia', 'tall', 2, 'oat', false, true, null)
Nobody can read that and tell what each argument means. C++, which GoF used as its main example, had no named arguments, so the only way to show the meaning of an argument at the call site was the method name. That is where passing values one at a time under names such as withSize(...) came from.
Writing it straightforwardly with classes
The subject here is a coffee order. Assembling it through a method chain is the standard form.
type Size = 'short' | 'tall' | 'grande';
type Milk = 'none' | 'whole' | 'oat';
type CoffeeOrderSpec = {bean: string; size: Size; shots: number; milk: Milk};
class CoffeeOrderBuilder {
private bean = '';
private size: Size = 'short';
private shots = 1;
private milk: Milk = 'none';
withBean(value: string): this {
this.bean = value;
return this;
}
withSize(value: Size): this {
this.size = value;
return this;
}
withShots(value: number): this {
this.shots = value;
return this;
}
withMilk(value: Milk): this {
this.milk = value;
return this;
}
build(): CoffeeOrderSpec {
return {bean: this.bean, size: this.size, shots: this.shots, milk: this.milk};
}
}
// You can call build() without ever specifying the bean, and the compiler says nothing
const incomplete = new CoffeeOrderBuilder().withSize('tall').build();
Readability does go up. But as the last line shows, forgetting a required value is not stopped by the type. An order carrying an empty bean gets built at runtime. GoF's Builder is about separating the construction procedure from the representation; stopping a forgotten required field lies outside that1.
Replacing it with language features
TypeScript has object literals that stand in for named arguments, and fields you can leave out are expressed with ?.
Much of the time, we'll find ourselves dealing with objects that might have a property set. In those cases, we can mark those properties as optional by adding a question mark (
?) to the end of their names.2
That takes care of the "too many arguments to read" motivation.
type Size = 'short' | 'tall' | 'grande';
type Milk = 'none' | 'whole' | 'oat';
type CoffeeOrderSpec = {
bean: string;
size: Size;
shots: number;
milk?: Milk;
};
const order: CoffeeOrderSpec = {
bean: 'ethiopia',
size: 'tall',
shots: 2,
};
Every field has a name, and the compiler catches a missing required one.
// error TS2741: Property 'bean' is missing in type '{ size: "tall"; shots: number; }'
// but required in type 'CoffeeOrderSpec'.
const broken: CoffeeOrderSpec = {
size: 'tall',
shots: 2,
};
It is shorter than the method-chain version, and a missing field is stopped by the type.
When the order carries meaning
Where a literal falls short is when the order in which values go in is itself constrained. If stages genuinely exist — the number of shots cannot be decided until the size is, nothing can be finalized until the bean and size are set — you can write the type so that it changes from stage to stage.
type Size = 'short' | 'tall' | 'grande';
type Milk = 'none' | 'whole' | 'oat';
type CoffeeOrderSpec = {bean: string; size: Size; shots: number; milk: Milk};
// Give each stage a type that holds only what can be called next
type NeedsSize = {size: (value: Size) => NeedsShots};
type NeedsShots = {shots: (value: number) => Ready};
type Ready = {
milk: (value: Milk) => Ready;
build: () => CoffeeOrderSpec;
};
function makeReady(spec: CoffeeOrderSpec): Ready {
return {
milk: (milk) => makeReady({...spec, milk}),
build: () => spec,
};
}
export function orderCoffee(bean: string): NeedsSize {
return {
size: (size) => ({
shots: (shots) => makeReady({bean, size, shots, milk: 'none'}),
}),
};
}
const done = orderCoffee('ethiopia').size('tall').shots(2).milk('oat').build();
Trying to finalize partway through stops, because the type at that point has no build.
// error TS2339: Property 'build' does not exist on type 'NeedsShots'.
const tooEarly = orderCoffee('ethiopia').size('tall').build();
Because each stage returns a different type, operations you cannot call yet never even come up as candidates. Editor completion lists only what is callable at that point. The forgotten field that the class version deferred to runtime has moved to compile time.
This form uses no special language feature; it is function types and object types combined.
How this series judges it
Verdict: conditional. The answer changes with the motivation.
If the only problem is that too many arguments make it unreadable, an object literal is enough. There is no benefit worth the effort of writing a method-chain builder. Omitting fields is ?, and defaults are written with destructuring.
Pick a staged builder when one of the following applies.
- There is a real constraint on the order values go in, and you want the type to show it
- You need to carry the intermediate state around (passing it to another function partway through assembly, for example)
Assembling test data splits the decision. One object holding the defaults, overwritten by spreading in only the differences, usually does the job. A staged builder is called for when the thing under test itself carries the ordering constraint.
What would overturn this judgment
A staged builder has a cost. You get one type per stage, and inserting a new stage in the middle changes every type that follows. Somewhere past three stages, the cost of maintaining the types tends to outweigh the benefit of preventing forgotten fields. That threshold is this guide's rule of thumb and is not based on measurement.
One more thing: the judgment that a literal suffices assumes required fields can be expressed in the type. A dependency between fields — set A and B becomes required too — cannot be expressed with ?. There, enumerating the combinations with a discriminated union comes out shorter than building a builder.
How this relates to existing articles
- Naming conventions 2: functions and classes lists
~Builderas a suffix for staged construction of a complex object, usingQueryBuilderas its example. That article is about how to name a class and does not take up whether to adopt the pattern. This chapter takes up exactly that question. - Testing Strategy covers test data builders in PHP. Both the language and the context differ, so it comes at this from another angle.
The basics of object types and optional fields are covered in Object types and type aliases.