Builder

Intermediate · 10 min read · ▶ live playground · ✦ checkpoint

The pizza arrives with stuffed crust and no extra sauce. The order asked for the reverse, but the call site contains two neighboring booleans, so the review saw true, false and approved the wrong meal.

A telescoping constructor is a constructor whose positional parameter list keeps growing as optional features are added. Each new option makes every call harder to read and easier to shift into the wrong position.

The Builder pattern constructs a complex object through named steps, then produces the finished representation with build(). It replaces positional ambiguity with a readable construction process. The cost is another stateful object, more methods, and a separate place for validation.

Before: position carries too much meaning

The constructor exposes every decision at once. Types catch a string in a boolean slot, but they cannot distinguish stuffedCrust from extraSauce because both are booleans.

class diagram

Before: Pizza has a telescoping constructor

Before: Pizza has a telescoping constructor
  1. 1Pizza asks one positional call to describe every required and optional choice.
  2. 2Adjacent booleans have the same type, so swapping their meaning still compiles.
  3. 3Each future option lengthens old call sites and adds another placeholder.

The call compiles, but you have to leave the constructor and count positions to know what it means:

type PizzaSize = "small" | "medium" | "large";
type Cheese = "mozzarella" | "cheddar";
 
class Pizza {
  public readonly size: PizzaSize;
  public readonly cheese: Cheese;
  public readonly stuffedCrust: boolean;
  public readonly extraSauce: boolean;
  public readonly toppings: readonly string[];
  public readonly note: string | undefined;
 
  public constructor(
    size: PizzaSize,
    cheese: Cheese,
    stuffedCrust: boolean,
    extraSauce: boolean,
    toppings: readonly string[],
    note: string | undefined,
  ) {
    this.size = size;
    this.cheese = cheese;
    this.stuffedCrust = stuffedCrust;
    this.extraSauce = extraSauce;
    this.toppings = [...toppings];
    this.note = note;
  }
}
 
const dinner = new Pizza(
  "large",
  "mozzarella",
  true,
  false,
  ["mushroom"],
  undefined,
);

Is true “stuffed crust” or “extra sauce”? The compiler knows only that both positions accept a boolean. An absent note still requires undefined because later arguments cannot name themselves.

After: name each construction step

PizzaBuilder owns the temporary, mutable construction state. Pizza owns the completed representation and exposes read-only fields. An optional PizzaDirector can preserve a common recipe as a named sequence of builder calls.

class diagram

After: a builder assembles an immutable Pizza

After: a builder assembles an immutable Pizza
  1. 1Pizza is the finished representation: callers receive a complete, read-only value.
  2. 2PizzaBuilder collects named choices and creates Pizza only when build() succeeds.
  3. 3PizzaDirector is optional; it records a reusable recipe as an ordered set of steps.
  4. 4Construction can change without turning Pizza's constructor into a positional puzzle.

A fluent interface is an API whose methods return an object that supports the next operation, allowing calls to read as a chain. Here every configuration method returns the same builder through this:

type PizzaSize = "small" | "medium" | "large";
type Cheese = "mozzarella" | "cheddar";
 
type PizzaDetails = {
  size: PizzaSize;
  cheese: Cheese;
  stuffedCrust: boolean;
  extraSauce: boolean;
  toppings: readonly string[];
};
 
class Pizza {
  public readonly size: PizzaSize;
  public readonly cheese: Cheese;
  public readonly stuffedCrust: boolean;
  public readonly extraSauce: boolean;
  public readonly toppings: readonly string[];
 
  public constructor(details: PizzaDetails) {
    this.size = details.size;
    this.cheese = details.cheese;
    this.stuffedCrust = details.stuffedCrust;
    this.extraSauce = details.extraSauce;
    this.toppings = [...details.toppings];
 
    Object.freeze(this.toppings);
    Object.freeze(this);
  }
 
  public snapshot(): PizzaDetails {
    return {
      size: this.size,
      cheese: this.cheese,
      stuffedCrust: this.stuffedCrust,
      extraSauce: this.extraSauce,
      toppings: this.toppings,
    };
  }
}
 
class PizzaBuilder {
  private size: PizzaSize | undefined;
  private cheese: Cheese = "mozzarella";
  private stuffedCrust = false;
  private extraSauce = false;
  private toppings: string[] = [];
 
  public setSize(size: PizzaSize): PizzaBuilder {
    this.size = size;
    return this;
  }
 
  public setCheese(cheese: Cheese): PizzaBuilder {
    this.cheese = cheese;
    return this;
  }
 
  public withStuffedCrust(enabled = true): PizzaBuilder {
    this.stuffedCrust = enabled;
    return this;
  }
 
  public withExtraSauce(enabled = true): PizzaBuilder {
    this.extraSauce = enabled;
    return this;
  }
 
  public addTopping(name: string): PizzaBuilder {
    this.toppings.push(name);
    return this;
  }
 
  public build(): Pizza {
    if (this.size === undefined) {
      throw new Error("Pizza size is required.");
    }
 
    return new Pizza({
      size: this.size,
      cheese: this.cheese,
      stuffedCrust: this.stuffedCrust,
      extraSauce: this.extraSauce,
      toppings: this.toppings,
    });
  }
}
 
class PizzaDirector {
  public makeMargherita(): Pizza {
    return new PizzaBuilder()
      .setSize("medium")
      .setCheese("mozzarella")
      .addTopping("basil")
      .build();
  }
}
 
const pizza = new PizzaBuilder()
  .setSize("large")
  .setCheese("mozzarella")
  .withExtraSauce()
  .addTopping("mushroom")
  .addTopping("olives")
  .build();
 
console.log(JSON.stringify(pizza.snapshot()));

{"size":"large","cheese":"mozzarella","stuffedCrust":false,"extraSauce":true,"toppings":["mushroom","olives"]}

The call site says what each choice means. build() also creates a validation boundary: it rejects a builder with no size before an incomplete Pizza can escape.

The product copies the toppings array before freezing itself. Without that copy, the mutable builder would retain a reference to the finished pizza's nested data. A later addTopping() could then alter an object that claims to be immutable.

The director is not required by the pattern. It earns a place when several callers need the same ordered recipe, such as “margherita” or “party pizza.” For one custom order, the client can drive the builder directly.

Why the chain does not violate the Law of Demeter

A method chain is a series of calls made on values returned by earlier calls. Chains such as order.customer().address().city() are risky because each step walks into another object's internal collaborator.

The builder chain is different. setSize(), addTopping(), and withExtraSauce() all return the same PizzaBuilder. You keep talking to one object rather than reaching through a chain of strangers. The final build() intentionally crosses the construction boundary and returns the product.

When NOT to use it

An object with two or three required fields does not need a builder. TypeScript's named options object already avoids positional ambiguity:

type BadgeOptions = {
  label: string;
  color: "blue" | "green";
  icon?: string;
};
 
const badge: BadgeOptions = {
  label: "Mentor",
  color: "blue",
};

That object is shorter, familiar, and easy to validate in a constructor or function. A builder would add mutable state and several forwarding methods without clarifying the creation process.

Use Builder when construction has many optional steps, order matters, validation belongs at a final boundary, or named recipes deserve a director. Avoid it when a small options object communicates the whole value at a glance.

Checkpoint

Answer all three to mark this lesson complete

Builder assembles one new object through explicit steps. Next, Prototype starts from an object that is already configured and makes an independent copy.

+50 XP on completion