Constructors have a scaling problem. When an object has 3 parameters, a constructor is fine. When it has 8, it’s a nightmare:

// What do these arguments mean? Who knows.
const email = new Email(
  "[email protected]",
  ["[email protected]", "[email protected]"],
  [],
  "Meeting Tomorrow",
  "Let's sync at 3pm",
  true,
  "high",
  undefined,
  new Date("2024-03-15"),
);

What’s that true? What’s "high"? Why is there an undefined in the middle? This code is write-only — nobody can read it without checking the constructor signature.

The Builder pattern fixes this by constructing objects step by step, with named methods instead of positional arguments.

A Simple Builder

class EmailBuilder {
  private from: string = "";
  private to: string[] = [];
  private cc: string[] = [];
  private subject: string = "";
  private body: string = "";
  private isHtml: boolean = false;
  private priority: "low" | "normal" | "high" = "normal";
  private attachments: Attachment[] = [];
  private scheduledAt?: Date;

  setFrom(from: string): this {
    this.from = from;
    return this;
  }

  addTo(...recipients: string[]): this {
    this.to.push(...recipients);
    return this;
  }

  addCc(...recipients: string[]): this {
    this.cc.push(...recipients);
    return this;
  }

  setSubject(subject: string): this {
    this.subject = subject;
    return this;
  }

  setBody(body: string, html: boolean = false): this {
    this.body = body;
    this.isHtml = html;
    return this;
  }

  setPriority(priority: "low" | "normal" | "high"): this {
    this.priority = priority;
    return this;
  }

  attach(attachment: Attachment): this {
    this.attachments.push(attachment);
    return this;
  }

  scheduleFor(date: Date): this {
    this.scheduledAt = date;
    return this;
  }

  build(): Email {
    if (!this.from) throw new Error("From address is required");
    if (this.to.length === 0) throw new Error("At least one recipient is required");
    if (!this.subject) throw new Error("Subject is required");

    return new Email(
      this.from,
      this.to,
      this.cc,
      this.subject,
      this.body,
      this.isHtml,
      this.priority,
      this.attachments,
      this.scheduledAt,
    );
  }
}

Now that unreadable constructor call becomes:

const email = new EmailBuilder()
  .setFrom("[email protected]")
  .addTo("[email protected]", "[email protected]")
  .setSubject("Meeting Tomorrow")
  .setBody("Let's sync at 3pm")
  .setPriority("high")
  .scheduleFor(new Date("2024-03-15"))
  .build();

Every parameter is named. Optional fields are simply omitted. The code reads like a specification.

Type-Safe Step Builders

The simple builder has a problem: you can call build() before setting required fields, and you only find out at runtime. A step builder uses TypeScript’s type system to enforce the order at compile time:

interface NeedsFrom {
  setFrom(from: string): NeedsTo;
}

interface NeedsTo {
  addTo(...recipients: string[]): NeedsSubject;
}

interface NeedsSubject {
  setSubject(subject: string): OptionalFields;
}

interface OptionalFields {
  setBody(body: string, html?: boolean): OptionalFields;
  setPriority(priority: "low" | "normal" | "high"): OptionalFields;
  addCc(...recipients: string[]): OptionalFields;
  attach(attachment: Attachment): OptionalFields;
  scheduleFor(date: Date): OptionalFields;
  build(): Email;
}

class StepEmailBuilder implements NeedsFrom, NeedsTo, NeedsSubject, OptionalFields {
  private data: Partial<EmailData> = {};

  private constructor() {}

  static create(): NeedsFrom {
    return new StepEmailBuilder();
  }

  setFrom(from: string): NeedsTo {
    this.data.from = from;
    return this;
  }

  addTo(...recipients: string[]): NeedsSubject {
    this.data.to = recipients;
    return this;
  }

  setSubject(subject: string): OptionalFields {
    this.data.subject = subject;
    return this;
  }

  setBody(body: string, html = false): OptionalFields {
    this.data.body = body;
    this.data.isHtml = html;
    return this;
  }

  setPriority(priority: "low" | "normal" | "high"): OptionalFields {
    this.data.priority = priority;
    return this;
  }

  addCc(...recipients: string[]): OptionalFields {
    this.data.cc = [...(this.data.cc ?? []), ...recipients];
    return this;
  }

  attach(attachment: Attachment): OptionalFields { /* ... */ return this; }
  scheduleFor(date: Date): OptionalFields { /* ... */ return this; }

  build(): Email {
    return new Email(this.data as EmailData);
  }
}

Now the type system guides you:

// ✅ Compiles — all required steps completed
const email = StepEmailBuilder.create()
  .setFrom("[email protected]")
  .addTo("[email protected]")
  .setSubject("Hello")
  .build();

// ❌ Compile error — can't call build() before setSubject()
const broken = StepEmailBuilder.create()
  .setFrom("[email protected]")
  .addTo("[email protected]")
  .build(); // Property 'build' does not exist on type 'NeedsSubject'

Impossible states become unrepresentable. That’s the gold standard.

The Functional Alternative: Options Object

Before reaching for a builder, consider whether a plain options object is enough:

interface EmailOptions {
  from: string;
  to: string[];
  subject: string;
  body?: string;
  html?: boolean;
  cc?: string[];
  priority?: "low" | "normal" | "high";
  attachments?: Attachment[];
  scheduledAt?: Date;
}

function sendEmail(options: EmailOptions): Promise<void> {
  const { from, to, subject, body = "", html = false, priority = "normal" } = options;
  // ...
}

// Usage:
await sendEmail({
  from: "[email protected]",
  to: ["[email protected]"],
  subject: "Meeting Tomorrow",
  priority: "high",
});

This is simpler than a builder and provides named parameters with TypeScript validation. For many cases, it’s the right choice.

Builder Pattern in Python

Python has several idiomatic approaches. Here’s a clean builder:

from dataclasses import dataclass, field
from typing import Self

@dataclass
class HttpRequest:
    method: str
    url: str
    headers: dict[str, str] = field(default_factory=dict)
    body: str | None = None
    timeout: int = 30
    retries: int = 0

class HttpRequestBuilder:
    def __init__(self, method: str, url: str):
        self._method = method
        self._url = url
        self._headers: dict[str, str] = {}
        self._body: str | None = None
        self._timeout: int = 30
        self._retries: int = 0

    def header(self, key: str, value: str) -> Self:
        self._headers[key] = value
        return self

    def body(self, body: str) -> Self:
        self._body = body
        return self

    def timeout(self, seconds: int) -> Self:
        self._timeout = seconds
        return self

    def retries(self, count: int) -> Self:
        self._retries = count
        return self

    def build(self) -> HttpRequest:
        return HttpRequest(
            method=self._method,
            url=self._url,
            headers=self._headers,
            body=self._body,
            timeout=self._timeout,
            retries=self._retries,
        )

# Usage:
request = (
    HttpRequestBuilder("POST", "https://api.example.com/users")
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer token123")
    .body('{"name": "Alice"}')
    .timeout(10)
    .retries(3)
    .build()
)

Though in Python, keyword arguments often eliminate the need for builders entirely:

request = HttpRequest(
    method="POST",
    url="https://api.example.com/users",
    headers={"Content-Type": "application/json"},
    body='{"name": "Alice"}',
    timeout=10,
    retries=3,
)

Builders for Test Data

One of the most valuable uses of builders is creating test objects. Instead of constructing complex objects in every test:

class UserBuilder {
  private data: UserData = {
    id: "test-user-1",
    name: "Test User",
    email: "[email protected]",
    role: "viewer",
    isActive: true,
    createdAt: new Date("2024-01-01"),
  };

  withId(id: string): this { this.data.id = id; return this; }
  withName(name: string): this { this.data.name = name; return this; }
  withEmail(email: string): this { this.data.email = email; return this; }
  withRole(role: UserRole): this { this.data.role = role; return this; }
  inactive(): this { this.data.isActive = false; return this; }
  admin(): this { this.data.role = "admin"; return this; }

  build(): User {
    return new User(this.data);
  }
}

// Tests become expressive:
const admin = new UserBuilder().admin().build();
const inactiveUser = new UserBuilder().inactive().withName("Gone User").build();
const specificUser = new UserBuilder()
  .withId("usr-42")
  .withEmail("[email protected]")
  .withRole("editor")
  .build();

This is cleaner than factory functions with 10 optional parameters and more flexible than fixture files.

When to Use Builders

Use a builder when:

  • Objects have many parameters (7+), especially with optional ones
  • Construction involves validation or complex logic
  • You want a fluent, readable API for configuration
  • You need to enforce construction order (step builder)
  • You’re creating test data with many variants

Use an options object instead when:

  • The language supports named parameters (Python, Kotlin)
  • There’s no construction logic beyond assignment
  • The object has fewer than 7 parameters
  • There’s no ordering requirement

Don’t use a builder when:

  • A simple constructor with 2-3 parameters works fine
  • You’re adding complexity to look clever
  • The object is a simple data container with no invariants

The builder pattern solves a real readability and safety problem. But like all patterns, it has a cost — more code, another class to maintain. Apply it where it earns its keep: complex objects with many configurations and real construction logic. For everything else, keep it simple.