← Design patterns
Creation Assembly and the handoff

Builder

Assemble a draft. Hand off a valid value.

A day out starts with one stop. Later, you add another place and decide to stay the night. The itinerary is still taking shape. When can the rest of the application treat it as a finished plan?

TypeScriptGoPython Same behavior · across the comparison.

01 / The idea

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.

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.

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 an independent plan that the caller can save or share.

That is the everyday form of Builder: one reusable builder with a validating handoff. The classic form, where a Director runs the same construction steps through interchangeable concrete builders to produce different representations, is a related conversation; Where the classic Director fits under Give it a real job says where the two differ.

Our question is also about ownership. If you build a plan and then add another stop, should the first plan quietly change? We’ll make the answer part of the contract: each built plan keeps the values it was given.

02 / See the shape

Each step edits the draft. Build checks the whole.

The Basic form collects a name and ordered places. Each Add Stop contributes a part; Build returns a separate list. It demonstrates assembly and handoff, without validating stays or trip kinds.

In the wild gives each stop a place and the nights spent there. A name setter replaces the previous name; adding a stop appends, preserving order. Calling Add Stop twice adds twice. Calling Build twice does not.

Build validates every stop and the combined route. Day trips require zero nights; overnight trips require at least one. A successful build totals the nights and copies the stops into a finished plan. A failed build leaves the draft available for correction.

A name and ordered places, assembled over several calls. Each step returns the builder; Build returns a separate value.

TypeScriptReading
trip.ts
export class BasicTripBuilder {
	private name: string;
	private places: string[] = [];
	constructor(name: string) {
		this.name = name;
	}
	addStop(place: string): this {
		this.places.push(place);
		return this;
	}
	build() {
		return { name: this.name, places: [...this.places] };
	}
}
GoAlongside
trip.go
type BasicTrip struct {
	Name   string
	Places []string
}
type BasicTripBuilder struct {
	name   string
	places []string
}

func NewBasicTripBuilder(name string) *BasicTripBuilder { return &BasicTripBuilder{name: name} }
func (b *BasicTripBuilder) AddStop(place string) *BasicTripBuilder {
	b.places = append(b.places, place)
	return b
}
func (b *BasicTripBuilder) Build() BasicTrip {
	return BasicTrip{b.name, append([]string{}, b.places...)}
}
The behavior every implementation promisesDefaults, validation order, and reuse

A new draft has an empty name, kind day-trip, and no stops. Name and kind setters replace their previous values. Add Stop appends an owned value; repeated places are allowed. Clear Stops removes all draft stops.

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. Go, Java, and Rust stop adding to their fixed-width validation total once it passes thirty; Python integers do not need that guard. The reported outcome is the same across the comparison for the shared cases.

Text is preserved exactly, including spaces and Unicode. “Empty” means the empty string. The rules are deliberately small: a route is ordered stops and nights, with no dates or travel times.

Build neither clears nor consumes this builder. Successful and failed builds leave its draft untouched. Repeated builds produce equal independent values until the draft changes. The total nights belongs to the product; it is derived from the stops, not stored as another editable draft field.

Reading the TypeScriptReturning this, copied stops, and runtime 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. Readonly helps the type checker; Object.freeze protects the completed product, its stop array, and every copied stop at runtime. Freezing only the outer object would leave nested values mutable.

JavaScript numbers include fractions and nonfinite values, so Number.isInteger is part of the stay check. The Go, Java, and Rust implementations use integer fields; they still need the range check.

Reading the GoPointer receivers and separate slice storage

The constructor returns *TripBuilder; setters use pointer receivers and return that same pointer. Use NewTripBuilder to establish the day-trip default. Keep one builder per planning flow and avoid copying a partly assembled builder by value.

Copying the stop slice copies each struct’s string and integer fields into separate storage. Build returns a TripPlan and an error. The caller’s returned plan is writable, but changing it does not change the builder or another plan.

Reading the PythonDataclasses, copies, and tuple products

The draft uses ordinary dataclasses and a mutable list. Each add_stop copies the incoming stop, and draft() returns another copy for inspection. The method returns the same builder so fluent calls continue to edit one draft.

A successful plan is a frozen dataclass whose stops are a tuple of frozen planned stops. That gives this sample an immutable product boundary; it is a deliberate choice, not a promise that every Python object accepted by a helper is immutable.

Python's bool is an integer subclass, so the stay check excludes booleans explicitly before applying the 0–14 range. Other failures raise ValueError from build; the builder remains available for repair.

03 / Follow the values

Build once. Then keep working on the draft.

Watch a day trip gain an overnight stay, or step through each decision. Before Build runs again, predict what happens to the earlier plan. In Try it, add your own stops, change the kind, and test the boundary.

Builder

Build a plan. Keep its values.

Draft Weekend route, day-trip: Harbor: 0 nights. Assemble the draft. No plans built yet.

Working itineraryday-trip

Weekend route

  1. 1Harbor0 nights

Assemble the draft

First plan

Build to hand off values.

Next plan

Keep assembling, then Build again.

01/ 04
Assemble the draft

A stop joins the draft.

Harbor has zero nights. No finished plan exists yet.

Reduced motion: choose a scene to see its completed state.

Read this scene

Harbor has zero nights. No finished plan exists yet.

Draft Weekend route, day-trip: Harbor: 0 nights. Assemble the draft. No plans built yet.

Adding Old town with one night makes the day-trip draft invalid. Plan 1 still describes Harbor with zero nights. Switching the draft to overnight repairs the combination; Build can now return Plan 2 without rewriting Plan 1.

04 / Try a decision

Some rules cannot be checked until the whole draft is known.

Suppose a reviewer asks why Add Stop accepts an overnight stay into a day trip instead of refusing it on the spot. Follow the shared case where the kind arrives after the stops before deciding where the check belongs.

A reviewer asks why Add Stop accepts an overnight stay into a day trip instead of refusing it. Where should that check live?

In the first shared case the draft is still a day trip when Old town (1 night) is added. Build reports the failure, then kind('overnight') repairs the plan without adding either stop again. Setters replace or append; only Build reads the whole draft.

05 / Give it a real job

The planner assembles. Save receives finished values.

Opening a planning session creates a builder for that session. A route suggestion can contribute the first stops; user choices add the rest. On Save, the caller invokes Build and either shows the error or passes the returned plan to storage.

View connections as text
  • Trip planner: Own the planning session, supply stops, and decide when to request a plan.
  • TripBuilder: Accumulate ordered stops and check the whole route at Build.
  • TripPlan: Own values from one successful build. Later draft changes do not rewrite them.
  • Save operation: Persist a finished plan. Storage and network failures belong to this operation.
  • Trip planner supplies choices TripBuilder
  • TripBuilder validates and copies TripPlan
  • TripPlan supplies finished values Save operation

Build does no network or booking work, so construction can be tested without making a reservation. Saving a valid plan can still fail if storage is unavailable, and the caller owns that later error.

Suppose the next version adds a total-night budget chosen by the user. The builder needs a draft choice and a rule comparing it with the assembled stays. Decide whether the product should retain that budget or only the route it approved. Existing plans must keep their original values when the next draft uses another budget.

Where the classic Director fitsConstruction steps and different representations

The classic Builder arrangement separates a construction sequence from the representation being assembled. A Director can supply ordered stops through construction operations. Concrete builders could turn the same sequence into a printable itinerary or a navigation instruction list.

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.

Decide what survives each operationReuse, failure, copying, and lifetime

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. This example has no concurrency control. 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. Go copies structs; TypeScript copies objects whose fields are a string and a number. This policy would need review if a stop gained nested mutable data.

Build UIs?Every file upload you send leans on a Build step you are told not to touch, and your forms can hand off the same way.

Where it already is in your components

You probably already follow this rule: when a form posts FormData with fetch, leave Content-Type alone. MDN warns against setting it, and SvelteKit’s enhance leaves it unset for multipart forms, with a comment saying so. 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. Setting multipart/form-data by hand, with no boundary, makes request.formData() on a Node 22 server reject with “Failed to parse body as FormData.” A shared request helper that sets a JSON header on everything fails too, with a message that the Content-Type was not a form type.

upload.ts
// 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 });
}

When you have to own it

Now the trip planner is a form, and the form already owns the draft: its fields hold the name, the kind, and the stops. Hand off the way fetch does. On each submission, create a fresh builder from a snapshot of those values and call Build. A long-lived builder that mirrors the form becomes a second source of truth, and a resubmitted form appends its stops to it a second time.

The submit handler in a Svelte or React component shows the error or passes the plan to save. A plain validator over the form record is also a good choice when there is no reusable construction process to encapsulate.

frontend.ts
import { TripBuilder, type TripPlan, type Stop } from './trip';
export type TripFormValues = { name: string; kind: string; stops: readonly Stop[] };

// Authored frontend application: adapt a snapshot of form state at submission.
// A fresh builder on each attempt prevents duplicate stops on resubmission.
export function planFromForm(values: TripFormValues): TripPlan {
	const builder = new TripBuilder().name(values.name).kind(values.kind);
	for (const stop of values.stops) builder.addStop(stop);
	return builder.build();
}
export function preparePreview(values: TripFormValues) {
	try {
		return { plan: planFromForm(values), error: null };
	} catch (error) {
		return { plan: null, error: error instanceof Error ? error.message : 'Build failed' };
	}
}
// A Svelte/React submit handler renders the error or hands the plan to a save operation.
// This adapter does no DOM manipulation, network work, or booking.

06 / Already in your toolbox

Look for assembly and a result, beyond the method names.

Each of these documented APIs assembles something in steps, and each makes its own promises about error timing, ownership, and the terminal operation.

Go’s strings.Builder

strings.Builder accumulates text through writes and exposes a string result through String. It illustrates repeated assembly without requiring a fluent setter chain. Its documentation explicitly says not to copy a nonzero Builder, a different ownership rule from our itinerary’s copies.

strings.Builder API →

A browser connection: URLSearchParams

append adds an ordered name/value pair and permits repeated names. It returns undefined, so chaining calls to it is not the interface. The connection is incremental assembly; there is no Build-time validation step.

URLSearchParams.append contract →

A nearby alternative: Go’s http.NewRequest

http.NewRequest accepts a method, URL, and body and returns a request with an error. That creation function is a useful counterexample: returning a configurable product does not by itself make an API a builder. Look for the staged construction responsibility.

http.NewRequest API →

07 / Make the choice

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.

SituationA reasonable starting pointWhat Builder changes
A handful of values arrive togetherPass a named options record to a function.A chain may add ceremony unless construction has more responsibilities.
Several steps contribute ordered partsKeep a draft record and a shared validator.Named operations can own accumulation, copying, and the final handoff.
A preset assembles the same structure repeatedlyA 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 existsValidate required fields at runtime.A staged or typestate builder can expose only valid next operations, at the cost of more types.
Three APIs can make different promisesMutable, persistent, and staged builders

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 would lose 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.

08 / Take the idea with you

Explain the draft and the handoff in one sentence.

“The planner adds ordered stops to a private draft; Build checks the whole route and returns an independent plan.” That explains the design without relying on the pattern name.

Try a transfer: add the total-night budget. Decide who supplies it, when it is checked, and what an earlier plan owns. Then explain which parts would still work with an options record and one validation function.

Connections to follow nextRelated lessons
  • Factory gives creation decisions a home. It can return a configured builder or a finished product; Builder describes assembly through steps and a handoff.
  • Abstract factory creates related products through a family contract. This builder produces one plan; its kind flag does not establish a family of collaborators.
  • Command packages an executable request. A builder could assemble its data; 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.