codejump

AcademyDesign Patterns

Builder pattern

Replacing a constructor drowning in optional parameters, the extra code it costs for objects simple enough not to need it, and build() handing back the same object twice.

6 min readUpdated

3 exercises

Waiting at the end of this page.

Builder solves a different failure mode than Singleton or the factories: not who builds the object, but how unreadable the constructor call becomes once an object has too many optional parts.

A Builder replaces a constructor with too many optional parameters with a fluent chain of setter-like calls that each return this, ending in a single build() that produces the finished object. Reach for it the moment a constructor call needs a comment to explain which positional argument is which.

The problem it solves

Look at this call:

TypeScript
const pizza = new Pizza('large', true, false, ['olives', 'feta'], null, 'thin', true);

Nobody can say what true, false or null mean without opening the class. Now picture the constructor five months later, after two more options were added.

  • Positional arguments are unreadable. The call site carries no names, so a swapped pair of booleans compiles and ships.
  • Optional parts multiply constructors. The "telescoping constructor" trap: one constructor with 3 arguments, one with 5, one with 7, each for a different combination.
  • Half-built objects leak. Setting fields one by one after new means the object exists in an invalid state between the calls.

A Builder gives every option a name, lets you set only the ones you care about, and hands you a valid object only at the end.

Structure

The client chains calls on the builder, and build() produces the finished, independent object
The client chains calls on the builder, and build() produces the finished, independent object

Two parts:

  • The builder, which collects settings (each setter returns this) and validates them.
  • The product, which is created once, complete, by build().
class Pizza {
  constructor(size, toppings) {
    this.size = size;
    this.toppings = toppings;
  }
}

class PizzaBuilder {
  #size = 'medium';
  #toppings = [];

  size(size) { this.#size = size; return this; }
  addTopping(topping) { this.#toppings.push(topping); return this; }
  build() { return new Pizza(this.#size, [...this.#toppings]); }
}

// new PizzaBuilder().size('large').addTopping('olives').addTopping('feta').build();

Every setter returns this (self in Python). That return value is the entire mechanism that lets the calls chain. Drop it from one method and the chain breaks at exactly that call, which is the most common Builder bug: a method that silently returns undefined or None stays invisible until someone chains through the one call that forgot it.

How a chain reads

Each call sets one option and returns the builder, until build() hands back the finished object
Each call sets one option and returns the builder, until build() hands back the finished object

The chain reads like a sentence, and every value has a name next to it. Compare it with the positional call above: nothing to memorize, and adding a new option later does not break existing chains.

Where it is used in real systems

Use caseWhy a builder helps
Query builders (Knex, Drizzle)A query has many optional clauses: where, order, limit, join. A chain assembles only the ones you need.
HTTP requestsMethod, headers, body, timeout and retries, most of them optional.
Validation schemas (Zod)z.string().min(1).max(50).email() is a builder: each call adds a rule and returns a new schema.
Test dataA buildUser() helper with sensible defaults and overrides, so a test states only what matters to it.
Configuration objectsA rate limiter, a cache or a logger with a dozen optional settings.
Emails and documentsRecipients, subject, attachments and a body assembled step by step.
Java StringBuilderAppending pieces in a loop, then producing one string at the end.

You have very likely used two of them today without calling them builders: Zod schemas and query builders.

Builder or an options object

In JavaScript and TypeScript there is a lighter alternative, and interviewers like to ask about it:

TypeScript
interface PizzaOptions {
  size?: string;
  toppings?: readonly string[];
}

function createPizza({ size = 'medium', toppings = [] }: PizzaOptions = {}): Pizza {
  return new Pizza(size, toppings);
}

createPizza({ size: 'large', toppings: ['olives', 'feta'] });

Named properties solve the readability problem with much less code. Prefer this when the object is simple and everything can be given at once.

A Builder still wins when:

  • Construction happens in steps, possibly across several functions, before the object is complete.
  • Validation matters. build() is one place to check that required parts are present and that options do not contradict each other.
  • The result must be immutable. The builder is mutable while you configure it, and build() produces a frozen object.
  • Some steps are conditional. A chain is easy to split with an if between two calls.

When not to use it

  • Few parameters. A constructor with two or three required arguments does not need a builder.
  • Everything is required. The point of a builder is optional parts. If nothing is optional, there is nothing to name.
  • An options object is enough. Do not write a class where an object literal with defaults does the job.

What interviewers ask

  • "What problem does Builder solve?" Constructors with many optional parameters, and the unreadable, error-prone calls they lead to.
  • "How does chaining work?" Every setter returns this.
  • "Builder versus a constructor with an options object?" The options object is simpler for one-shot construction. The builder wins for step-by-step assembly and validation at build().
  • "Where do you already use one?" Query builders, Zod schemas, test data helpers.
  • "What is the Director?" In the classic pattern, an optional class that knows the order of builder calls for common recipes ("a margherita"). Many codebases skip it.

The mistake to avoid

A Builder whose build() can be called twice and hand back the same mutable object both times. build() should return a fresh, independent instance, otherwise mutating one "finished" object mutates every other copy of it. The same goes for the internal list: copy it ([...this.toppings]) when building, or a later addTopping reaches back into an object you already handed out.

Builder is one naming device among several an object-oriented language gives you for controlling how new gets called. The mechanism above is the same, syntax aside, whether the language is JavaScript, Python or Java.

Now practice it

1 challenge · 2 quizzes

Reading this page is the cheap half. These are the exercises that make you use it.