Definition
The Builder pattern separates the construction of a complex object from its representation: the object is assembled step by step, and the same construction process can produce different representations.
After Gamma et al., Design Patterns (1994), Builder
01Start with one call
Some values arrive one decision at a time.
You’re building a trip planner. The first version accepts a name and a list of places. A
plain record passed to a function is clear when all those values arrive together: one call,
one place that checks the whole trip, one plan back. Sketched, with planTrip standing for that function:
// Every value arrives together, so one call can check them all and return the plan.
const plan = planTrip({
name: 'Weekend route',
kind: 'overnight',
stops: [
{ place: 'Harbor', nights: 0 },
{ place: 'Old town', nights: 1 },
],
}); That is the version to keep while it fits. Then the planning flow grows. A suggested route contributes the first stop. The user adds more in order and chooses how many nights to stay at each. The trip can be a day trip or an overnight trip. An overnight stay is a perfectly reasonable choice on its own, but it cannot belong to a finished day-trip plan.
Now there is no moment when every value is in hand. A half-built record travels from the suggestion to the stop editor to the kind picker, each screen pushes into it, and somebody has to decide when it is finished. If you build a plan and then add another stop, should the first plan quietly change?
02Give the draft an owner
Each step edits the draft. Build checks the whole.
Builder gives staged construction its own object, which collects parts and produces a
result at a deliberate handoff. Here, the builder owns an editable itinerary. Its build operation checks the combined
choices and returns a plan that the caller can save or share. This is the whole program the lab
runs: the builder and one planning session.
type Stop = { place: string; nights: number };
type TripPlan = { name: string; kind: string; stops: Stop[]; nights: number };
// The builder owns the draft: setters replace, addStop appends.
class TripBuilder {
private tripName = '';
private tripKind = 'day-trip';
private stops: Stop[] = [];
name(value: string): this {
this.tripName = value;
return this;
}
kind(value: string): this {
this.tripKind = value;
return this;
}
addStop(stop: Stop): this {
this.stops.push({ ...stop });
return this;
}
// build checks the whole draft, then returns the plan.
build(): TripPlan {
const nights = this.stops.reduce((total, stop) => total + stop.nights, 0);
if (this.tripKind === 'day-trip' && nights > 0)
throw new Error('a day trip cannot include an overnight stay');
if (this.tripKind === 'overnight' && nights === 0)
throw new Error('an overnight trip needs at least one night');
const stops = this.stops.map((stop) => ({ ...stop }));
return { name: this.tripName, kind: this.tripKind, stops, nights };
}
}
const show = (plan: TripPlan) => {
const route = plan.stops.map((stop) => stop.place).join(' → ') || 'no stops';
return `${plan.name}, ${plan.kind}: ${route}, nights=${plan.nights}`;
};
const builder = new TripBuilder().name('Weekend route');
builder.addStop({ place: 'Harbor', nights: 0 });
const first = builder.build();
builder.addStop({ place: 'Old town', nights: 1 });
let blocked = '';
try {
builder.build();
} catch (error) {
blocked = (error as Error).message;
}
builder.kind('overnight');
const second = builder.build();
console.log(`first: ${show(first)}`);
console.log(`blocked: ${blocked}`);
console.log(`second: ${show(second)}`); | Part | Role | What it handles |
|---|---|---|
| Trip planner | Caller | Owns the planning session, supplies stops, and decides when to request a plan. |
| TripBuilder | Draft owner | Accumulates ordered stops and checks the whole route at build. |
| TripPlan | Product | Holds the values from one successful build. |
| Save operation | Consumer | Persists a finished plan. Storage and network failures belong to this operation. |
A name setter replaces the previous name; adding a stop appends, preserving order. Calling add stop twice adds twice. Building itself does not book or save anything: when the user selects Save, the caller asks for a plan and passes a successful one to storage.
What a production builder addsEvery rule, in a fixed order
A new draft has an empty name, kind day-trip, and no stops. Name and kind
setters replace their previous values. Add stop appends; repeated places are allowed.
Clear stops removes all draft stops. Setters accept invalid intermediate choices: build
validates.
Build checks, in order: a nonempty trip name; a known kind, day-trip or overnight; at least one stop; then each stop in order, checking its name
before its stay. Each stay must be a whole number from zero to fourteen nights. After
checking all stops, build enforces the trip-kind rule, then a maximum of thirty nights in
total. The first failed rule becomes the error, and a failed build leaves the draft as it
was, ready to repair. Text is preserved exactly, including spaces and Unicode; “empty”
means the empty string.
export type Stop = Readonly<{ place: string; nights: number }>;
export type TripPlan = Readonly<{
name: string;
kind: 'day-trip' | 'overnight';
stops: readonly Stop[];
nights: number;
}>;
export class TripBuilder {
private tripName = '';
private tripKind = 'day-trip';
private stops: Stop[] = [];
name(value: string): this {
this.tripName = value;
return this;
}
kind(value: string): this {
this.tripKind = value;
return this;
}
addStop(stop: Stop): this {
this.stops.push({ ...stop });
return this;
}
clearStops(): this {
this.stops = [];
return this;
}
// Checks the whole draft; the first rule that fails is the error.
// A failed build leaves the draft as it was, ready to repair.
build(): TripPlan {
const kind = this.tripKind;
if (this.tripName === '') throw new Error('trip name must not be empty');
if (kind !== 'day-trip' && kind !== 'overnight') throw new Error('unknown trip kind');
if (this.stops.length === 0) throw new Error('add at least one stop');
let nights = 0;
for (const stop of this.stops) {
if (stop.place === '') throw new Error('stop name must not be empty');
if (!Number.isInteger(stop.nights) || stop.nights < 0 || stop.nights > 14)
throw new Error('stay must be 0 to 14 whole nights');
nights += stop.nights;
}
if (kind === 'day-trip' && nights !== 0)
throw new Error('a day trip cannot include an overnight stay');
if (kind === 'overnight' && nights === 0)
throw new Error('an overnight trip needs at least one night');
if (nights > 30) throw new Error('a trip cannot exceed 30 nights');
// Freeze the plan, its array and every stop: freezing only the outer
// object would leave the stops editable.
const stops = Object.freeze(this.stops.map((stop) => Object.freeze({ ...stop })));
return Object.freeze({ name: this.tripName, kind, stops, nights });
}
} JavaScript numbers include fractions and non-finite values, so Number.isInteger is part of the stay check. The plan, its stops array and every
stop are frozen. The rules are deliberately small: a route is ordered stops and nights, with no dates or travel
times.
03Predict the handoff
Build once. Then keep working on the draft.
Experiment
One draft, two plans
// Lines 30–31 become a return that hands out the draft's own array:
return { name: this.tripName, kind: this.tripKind, stops: this.stops, nights }; Reading the TypeScriptReturning this, copied stops, and freezing
A method returning this hands back the same mutable builder. Assigning it to
another variable does not create a branch. The private fields retain choices between calls.
The builder copies incoming stops, then copies again at build, so neither the caller’s
object nor a later change to the draft reaches a plan. Readonly helps the
type checker; in production, Object.freeze protects the plan, its stop array,
and every copied stop at runtime. Freezing only the outer object would leave nested values
mutable.
04Meet it in your language
You already use builders. Each has its own rule.
You probably already follow this rule: when a form posts FormData with fetch, leave Content-Type alone. The reason is this lesson’s
handoff. FormData is a draft: append adds entries, repeated
names included, and nothing is encoded yet. The multipart body is built when the draft
becomes a request, and the Fetch standard has that step produce two things together: the encoded entries, separated by a boundary string
it generates, and the type multipart/form-data; boundary=… naming that boundary.
The header is derived at build, the way our plan’s night total is.
Set the header yourself and the request keeps yours, because the derived type is only
added when no Content-Type is present. The body still uses a boundary; the
header no longer names it. In Node 22.21, reading such a request back with request.formData() rejects with TypeError: Failed to parse body as FormData.
// Breaks: your header wins, and it names no boundary.
export function uploadWithHeader(url: string, form: FormData): Request {
return new Request(url, {
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' },
body: form
});
}
// Works: building the request encodes the entries and derives Content-Type.
export function upload(url: string, form: FormData): Request {
return new Request(url, { method: 'POST', body: form });
} A shared request helper that sets a JSON header on everything fails the same way, with a
message that the Content-Type was not a form type. SvelteKit 2.70’s enhance leaves the header unset for multipart forms, with a comment saying so.
| API | The handoff and its rule |
|---|---|
| FormData · append, then fetch | The request encodes the entries and derives Content-Type, boundary included. |
| URLSearchParams · append | Ordered pairs, repeated names allowed; toString writes the query. Nothing is validated. |
| Array · push, then join | The everyday builder: collect parts, then make one string at the end. |
05Decide when it helps
Many fields alone do not require many methods.
I’d keep an options record and a validation function when all values are already available together. They can enforce the same final rules with less machinery. Builder helps when named assembly steps, repeated parts, reusable recipes, or a controlled handoff make the construction process easier to use.
| Situation | A reasonable starting point | What Builder changes |
|---|---|---|
| A handful of values arrive together | Pass a named options record to a function. | A chain may add ceremony unless construction has more responsibilities. |
| Several steps contribute ordered parts | Keep a draft record and a shared validator. | Named operations can own accumulation, copying, and the final handoff. |
| A preset assembles the same structure repeatedly | A helper can return a configured value. | A recipe or Director can drive shared construction steps and allow further assembly. |
| A required step must happen before build exists | Validate required fields at runtime. | A staged or typestate builder can expose only valid next operations, at the cost of more types. |
What goes in the setters, and what in build. Setters record choices. A rule about one part, a stop’s name, can be checked as it arrives if you want early feedback; a rule about the combination, a day trip with a night in it, can only be checked when the whole draft exists, so it belongs to build, whatever order the parts arrived in. Values derived from the parts, the night total, belong to the product, computed at build, never a second editable field in the draft.
Where the classic Director fitsConstruction steps and different representations
The classic Builder arrangement separates a construction sequence from the representation being assembled. A Director supplies the steps, in order, through an interface every builder shares; concrete builders could turn the same sequence of stops into a printable itinerary or a list of navigation instructions. The Director knows the order of the steps, not what any builder makes of them.
A Director is useful when the sequence itself needs reuse. It can be a function; the caller can also drive the steps directly. Our example uses one concrete reusable builder with a final validation boundary. Its day-trip/overnight choice is a rule on one product, not two interchangeable concrete builders.
Three builders can make different promisesMutable, immutable, and staged
Our mutable builder updates one draft. Two variables referring to it refer to the same construction state. An immutable builder returns a new builder from each step, allowing intentional branches; ignoring that returned builder loses the step.
A staged builder exposes different operations at different points. For example, build may become available only after a name is supplied. That can reject a missing step during type checking, but a name loaded from a form may still be an empty string and need runtime validation.
Choose the validity and reuse rules before choosing the syntax. A terminal method might only assemble data, consume a resource, or start I/O. The name build does not settle those questions.
Decide what survives each operationReuse, sharing, copying
Make reuse explicit. This builder remains usable after success and failure. Another API might reset after build or consume itself. A caller should be able to tell which rule applies.
Keep builders local. Sharing one mutable builder across two requests can mix their routes. A reusable route recipe should create a fresh builder or contribute values to a caller-owned builder.
Count the copies you chose. Each build visits and copies the stop list. That is cheap for a few stops of a string and a number; it would need review if a stop gained nested mutable data.
Its neighbours: Factory gives creation decisions a home; it can return a configured builder or a finished product. Abstract factory creates related products through a family contract; this builder produces one plan, and its kind flag does not establish a family. Command packages an executable request: a builder could assemble its data, and constructing a request and executing it remain separate operations. Prototype starts from an existing instance: copying a finished itinerary could seed a draft, but that duplication policy is a separate decision from how later parts are assembled.
06Make the call
Use a builder when the assembly deserves an owner.
When every trip value arrives together, an options record and one validator are simpler. Keep the builder when a suggestion and later user choices contribute parts over time, or when several callers need the same named assembly steps and handoff.
Before you open the answer below, say it in your own words: what can the caller keep changing, what does a successful build hand off, and when is a record and a validator enough?
One way to say itThe draft, the handoff, and when not to
The caller keeps changing the draft: setters replace single choices, adders append parts, in whatever order the parts arrive. Build checks everything that needs the whole draft (rules relating parts, values derived from them) and hands off a product that owns its values, so later edits to the draft never reach a plan already built. A failed build leaves the draft to repair. The builder is one draft, so it lives as long as one construction session and is not shared between them; in a form, the form is the draft, and each submission builds from a snapshot. When every value arrives at once, write a record and a validator: a builder earns its methods through assembly over time, repeated parts, or a construction process several callers reuse.
Practice in the lab
The trip planner’s saved-trips page: a search box, filter chips, a sort menu and a pager each add to one search as the user acts, and the list page runs it as SQL with numbered parameters. Each level starts from the previous level’s finished code, so you can begin at any of them.
- BUILDER-101 (easy) Sort first, and the SQL breaks
- BUILDER-102 (medium) Saved searches need a label in the sidebar Pro
- BUILDER-103 (hard) Review: two tabs, one search Pro