Builder separates how a complex object is assembled from the object itself. Instead of one constructor call with a dozen arguments, you make a series of small, named calls and finish with build(). Each step is simple; the builder makes sure the result is complete and valid.
Difficulty: ★★★☆☆. Popularity: ★★★☆☆ for writing your own in JavaScript, much higher for using them: every query builder, test fixture builder and fluent HTTP client is one.
First: you may only need an options object
Java made Builder famous because Java has no named arguments, so a constructor with eight parameters is unreadable. TypeScript has object literals, which solve most of that problem directly:
type EmailOptions = {
to: string[];
subject: string;
html: string;
cc?: string[];
replyTo?: string;
attachments?: { filename: string; content: string }[];
};
function sendEmail(options: EmailOptions) {
const { to, subject, cc = [], attachments = [] } = options;
return `to=${to.join(",")} cc=${cc.length} subject="${subject}" attachments=${attachments.length}`;
}
console.log(sendEmail({ to: ["an@example.com"], subject: "Your receipt", html: "<p>Thanks!</p>" }));
// to=an@example.com cc=0 subject="Your receipt" attachments=0
Named, optional, typed, defaulted. If your object is a bag of independent settings, stop here; a builder would only add code.
A builder earns its place when construction has structure: parts that depend on each other, parts added conditionally from runtime input, ordering rules, validation of the whole, or an output that has to be rendered (SQL, a URL, a document). Product search is a good example.
The problem: building a query by string concatenation
The product listing endpoint accepts optional filters from the query string. The first version builds SQL directly:
function searchSql(q: { category?: string; search?: string; min?: number; max?: number; sort?: string; page?: number }) {
let sql = "SELECT id, name, price FROM products WHERE 1=1";
if (q.category) sql += ` AND category_slug = '${q.category}'`;
if (q.search) sql += ` AND name ILIKE '%${q.search}%'`;
if (q.min) sql += ` AND price >= ${q.min}`;
if (q.max) sql += ` AND price <= ${q.max}`;
sql += q.sort === "price-asc" ? " ORDER BY price ASC" : " ORDER BY created_at DESC";
sql += ` LIMIT 20 OFFSET ${((q.page ?? 1) - 1) * 20}`;
return sql;
}
Problems, in order of severity:
- SQL injection.
search = "x' OR '1'='1"rewrites the query. - Fragile ordering.
ORDER BYmust come after everyWHEREclause and beforeLIMIT. Add a filter in the wrong place and the query breaks. - Bugs hiding in conditions.
if (q.min)skipsmin = 0. - No validation.
page = -5orpageSize = 100000go straight to the database. - Not reusable. The admin screen needs the same filters plus "include hidden products", so the function gets copied.
The pattern: a builder that owns the structure
The builder collects parts in any order, binds every value as a parameter, validates, and renders the final query in build():
type Sort = "newest" | "price-asc" | "price-desc";
const ORDER_BY: Record<Sort, string> = {
newest: "created_at DESC",
"price-asc": "price ASC",
"price-desc": "price DESC",
};
export class ProductQueryBuilder {
private readonly conditions: string[] = ["is_visible = true"];
private readonly values: unknown[] = [];
private sort: Sort = "newest";
private page = 1;
private pageSize = 20;
/** Every value goes through here, so nothing is ever concatenated into SQL. */
private bind(value: unknown) {
this.values.push(value);
return `$${this.values.length}`;
}
inCategory(slug: string) {
this.conditions.push(`category_slug = ${this.bind(slug)}`);
return this;
}
search(term: string) {
const trimmed = term.trim();
if (trimmed) this.conditions.push(`name ILIKE ${this.bind(`%${trimmed}%`)}`);
return this;
}
priceBetween(min?: number, max?: number) {
if (min !== undefined) this.conditions.push(`price >= ${this.bind(min)}`);
if (max !== undefined) this.conditions.push(`price <= ${this.bind(max)}`);
return this;
}
inStockOnly(enabled = true) {
if (enabled) this.conditions.push("stock > 0");
return this;
}
includeHidden() {
const index = this.conditions.indexOf("is_visible = true");
if (index !== -1) this.conditions.splice(index, 1);
return this;
}
sortBy(sort: Sort) {
this.sort = sort;
return this;
}
paginate(page: number, pageSize = 20) {
if (!Number.isInteger(page) || page < 1) throw new RangeError("page must be a positive integer");
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) throw new RangeError("pageSize must be 1-100");
this.page = page;
this.pageSize = pageSize;
return this;
}
build(): Readonly<{ text: string; values: unknown[] }> {
const values = [...this.values, this.pageSize, (this.page - 1) * this.pageSize];
const where = this.conditions.length ? ` WHERE ${this.conditions.join(" AND ")}` : "";
const text =
`SELECT id, name, price FROM products${where}` +
` ORDER BY ${ORDER_BY[this.sort]} LIMIT $${values.length - 1} OFFSET $${values.length}`;
return Object.freeze({ text, values });
}
}
Each method returns this, which makes the fluent chain possible. Conditional parts stay readable because the builder, not the caller, decides where they go in the output:
type SearchParams = { category?: string; q?: string; min?: string; max?: string; inStock?: string; sort?: Sort; page?: string };
const toNumber = (value?: string) => (value === undefined || value === "" ? undefined : Number(value));
export function productSearchQuery(params: SearchParams) {
const builder = new ProductQueryBuilder()
.search(params.q ?? "")
.priceBetween(toNumber(params.min), toNumber(params.max))
.inStockOnly(params.inStock === "1")
.sortBy(params.sort ?? "newest")
.paginate(Number(params.page ?? 1));
if (params.category) builder.inCategory(params.category);
return builder.build();
}
console.log(productSearchQuery({ category: "mugs", q: "x' OR '1'='1", min: "0", sort: "price-asc", page: "2" }));
// (text wrapped for readability)
// {
// text: "SELECT id, name, price FROM products WHERE is_visible = true AND name ILIKE $1 AND price >= $2
// AND category_slug = $3 ORDER BY price ASC LIMIT $4 OFFSET $5",
// values: [ "%x' OR '1'='1%", 0, "mugs", 20, 20 ],
// }
The injection attempt is now just a search string, min = 0 is kept, inCategory was called last but its condition lands in the right place, and the admin screen reuses everything with one extra .includeHidden(). The { text, values } shape is exactly what pg's client.query() accepts.
In a real project you would use an existing builder such as Kysely, Knex or Drizzle instead of writing your own. Knowing the pattern is what lets you use them well and extend them with your own reusable pieces.
A director for recipes you repeat
The GoF Builder has an optional director: an object or function that knows a recipe and drives the builder through it. In TypeScript it is usually a plain function that takes a builder:
const bestSellersUnder = (maxPrice: number) => (builder: ProductQueryBuilder) =>
builder.inStockOnly().priceBetween(undefined, maxPrice).sortBy("price-desc").paginate(1, 12);
console.log(bestSellersUnder(300_000)(new ProductQueryBuilder()).build().text);
// SELECT id, name, price FROM products WHERE is_visible = true AND stock > 0 AND price <= $1
// ORDER BY price DESC LIMIT $2 OFFSET $3
Test data builders
The most useful builder in most codebases is not in production code at all. Tests need valid objects that differ in one detail, and a builder with sensible defaults makes each test say only what matters to it:
type OrderStatus = "pending" | "paid" | "shipped" | "cancelled";
type Order = {
id: string;
customerId: string;
status: OrderStatus;
items: { sku: string; quantity: number; unitPrice: number }[];
couponCode: string | null;
};
export class OrderBuilder {
private order: Order = {
id: "o-1",
customerId: "c-1",
status: "pending",
items: [{ sku: "MUG", quantity: 1, unitPrice: 120_000 }],
couponCode: null,
};
paid() {
this.order.status = "paid";
return this;
}
withItems(...items: Order["items"]) {
this.order.items = items;
return this;
}
withCoupon(code: string) {
this.order.couponCode = code;
return this;
}
build(): Order {
return structuredClone(this.order); // each build is independent
}
}
const order = new OrderBuilder().paid().withCoupon("WELCOME").build();
console.log(order.status, order.couponCode, order.items.length); // paid WELCOME 1
When the Order type gains a required field, you add a default in one place instead of fixing two hundred test literals.
A builder TypeScript can check
A runtime builder can only complain at build() time if a required part is missing. With a little generic typing, the compiler can refuse to give you build() until the required steps are done:
type Missing = "to" | "subject" | "body";
class EmailBuilder<Pending extends Missing = Missing> {
declare private readonly pending: Pending; // type-only: makes EmailBuilder<"to"> and EmailBuilder<never> differ
private data: Partial<{ to: string[]; subject: string; html: string }> = {};
to(...addresses: string[]) {
this.data.to = addresses;
return this as unknown as EmailBuilder<Exclude<Pending, "to">>;
}
subject(subject: string) {
this.data.subject = subject;
return this as unknown as EmailBuilder<Exclude<Pending, "subject">>;
}
body(html: string) {
this.data.html = html;
return this as unknown as EmailBuilder<Exclude<Pending, "body">>;
}
build(this: EmailBuilder<never>) {
return this.data as Required<typeof this.data>;
}
}
const email = new EmailBuilder().to("an@example.com").subject("Welcome").body("<p>Hi</p>").build();
console.log(email.subject); // Welcome
// new EmailBuilder().to("an@example.com").build();
// ^ error TS2684: The 'this' context of type 'EmailBuilder<"body" | "subject">'
// is not assignable to method's 'this' of type 'EmailBuilder<never>'.
The this: EmailBuilder<never> parameter means build() is only callable once every pending step has been removed from the type. The declared pending field matters more than it looks: without a member that actually uses Pending, TypeScript compares the builders structurally, finds them identical, and lets the incomplete build() compile. I only caught that by writing the failing case as a test. Use this technique sparingly; it is clever, and clever code has a cost for the next reader.
Builder vs Prototype
Both create objects, from opposite directions:
- Prototype starts from a finished object and copies it. Good when variants are small changes to a stable baseline.
- Builder starts from nothing and assembles parts. Good when the structure itself varies from one object to the next.
An apartment block where every unit has the same layout is a prototype problem. Custom houses that all have a kitchen, bedrooms and windows, but in a different arrangement each time, are a builder problem.
When to use it, and when not to
Use it when:
- The output has structure and ordering rules (queries, URLs, documents, messages).
- Parts are added conditionally from runtime input.
- The finished object should be validated as a whole and be immutable afterwards.
- Tests need many valid variations of one object.
Skip it when:
- The object is a set of independent optional settings. Use an options object.
- There is a mature library builder for the job (SQL, HTTP, schemas). Use it.
Common pitfalls
- Reusing a builder after
build(). Ifbuild()returns internal arrays, later calls on the builder mutate the "finished" object. Copy or freeze on build, as above. - Setters with no logic. A builder whose methods only assign fields is an options object with extra steps.
- Validation in every setter but not on the whole. Rules like "
maxmust be at leastmin" can only be checked inbuild(). - Method explosion. Every new attribute is a new method. Group related settings (
priceBetween(min, max)) instead of one method per field.
Exercises
- Add
build()validation that throws ifminis greater thanmaxinProductQueryBuilder. Write the test first. - Add
withTags(...tags)that generatestags && $n::text[]with the array bound as a single parameter. - Extend
OrderBuilderwithshippedTo(province)and use it to rewrite three existing tests in a codebase you work on. - Add an optional
cc()step toEmailBuilderthat does not affect whetherbuild()is allowed.