The Builder Pattern in TypeScript
Build complex objects step by step with fluent APIs, type-safe builders, and validation. Learn when the Builder pattern shines and when it's overkill.
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.