Builder
class Order {
constructor(
readonly dish: string,
readonly quantity: number,
readonly spicy: boolean,
readonly noOnion: boolean,
readonly contactless: boolean,
readonly note?: string,
) {}
}
const order = new Order('Masala dosa', 2, true, false, true)
console.log(order.noOnion) // falseQuick, without looking up: in that new Order(...) call, which true is “spicy” and
which is “contactless”? Someone on the app team got it backwards. For a week, every
customer who ticked “no onion” got a contactless delivery, with onions.
The compiler could not help. Three booleans in a row are all the same type.
An Order has a dish, a quantity, a spice level, a list of extras, and an optional note. Most orders set only one or two of these. How would you let callers create one so the call site is readable and an invalid order cannot be made?
The idea
“One pani puri. Medium spicy. No onion. Extra sev.” You say the parts one by one, in any order, and skip what you do not care about. He assembles it as you talk and hands you the plate only when it is complete. You never hold a half made plate.
The builder
type SpiceLevel = 'mild' | 'medium' | 'hot'
type Order = {
readonly dish: string
readonly quantity: number
readonly spice: SpiceLevel
readonly extras: readonly string[]
readonly note?: string
}
class OrderBuilder {
private quantity = 1
private spice: SpiceLevel = 'medium'
private extras: string[] = []
private note?: string
constructor(private readonly dish: string) {}
times(quantity: number): this {
this.quantity = quantity
return this
}
spiceLevel(spice: SpiceLevel): this {
this.spice = spice
return this
}
add(extra: string): this {
this.extras.push(extra)
return this
}
withNote(note: string): this {
this.note = note
return this
}
build(): Order {
if (this.quantity < 1) throw new Error('Quantity must be at least 1')
if (this.extras.length > 5) throw new Error('At most 5 extras')
return {
dish: this.dish,
quantity: this.quantity,
spice: this.spice,
extras: [...this.extras],
note: this.note,
}
}
}
const order = new OrderBuilder('Masala dosa')
.times(2)
.spiceLevel('hot')
.add('butter')
.add('extra chutney')
.build()
console.log(order.quantity) // 2
console.log(order.extras) // [ 'butter', 'extra chutney' ]
console.log(order.spice) // hot
order.quantity = 5
// Error: Cannot assign to 'quantity' because it is a read-only property.Read the call site again. Every value has a name next to it. Nothing is positional, so
nothing can be swapped. Defaults live in one place, in the builder’s fields. And the
Order that comes out is read only, so the checks in build() hold for as long as it
exists.
Four things in the code are worth a closer look.
Each setter returns this. That is what makes the chain work: .times(2) gives back
the same builder, so .spiceLevel('hot') can be called on the result. The return type is
written this, which TypeScript treats as “whatever class this method was called on”.
build() is the only door out. It is the one place that checks rules involving more
than one field, and the one place an Order gets made.
[...this.extras] is a copy. Without it, the order and the builder would share one
array, and calling .add() on the builder afterwards would change an order that was
already built.
The required value goes in the constructor. You cannot have an order without a dish,
so new OrderBuilder() with no dish does not compile. Optional things become methods.
try {
new OrderBuilder('Idli').times(0).build()
} catch (error) {
if (error instanceof Error) console.log(error.message)
// > Quantity must be at least 1
}The TypeScript shortcut: an options object
Builder was invented for languages without named arguments. JavaScript has had object literals from the start, and they solve the readability half of the problem on their own.
type SpiceLevel = 'mild' | 'medium' | 'hot'
type OrderOptions = {
dish: string
quantity?: number
spice?: SpiceLevel
extras?: string[]
note?: string
}
function createOrder({
dish,
quantity = 1,
spice = 'medium',
extras = [],
note,
}: OrderOptions) {
if (quantity < 1) throw new Error('Quantity must be at least 1')
return { dish, quantity, spice, extras: [...extras], note } as const
}
const order = createOrder({
dish: 'Masala dosa',
quantity: 2,
extras: ['butter'],
})
console.log(order.spice) // medium
console.log(order.extras) // [ 'butter' ]Named values, defaults, validation, and no class. { dish, quantity = 1, ... } in the
parameter list is destructuring with defaults: pull these properties out of the argument,
and use the default when one is missing.
For most code this is the right answer and you should start here.
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Options object | Readable call sites, defaults and validation in about ten lines, with no extra class. | Everything has to be known in one place, at one moment. | The default in TypeScript. The caller has all the values at hand. |
| Builder class | The object can be assembled across several steps or functions, and the fluent chain reads like a sentence. | A second class to maintain that mirrors the first, and a build() call to remember. | Construction happens in stages, or you are designing a fluent API such as a query builder or a test data builder. |
Where builders really pay off in TypeScript is test data: anOrder().forCustomer('asha').paid().build()
gives each test a valid order while stating only the detail that test cares about. SQL
query builders are the other everyday example.
If build() returns this.extras directly, or returns the builder’s own state object,
the caller and the builder share memory. Reuse the builder for a second order and the
first one changes. Always return a fresh object from build(), with copies of any
arrays.
It is tempting to check in each setter. But a rule such as “express delivery needs a
phone number” involves two fields that are set in an unknown order, so no single setter
can check it. Do checks that span fields in build(), where everything is known.
Because then the object exists in an invalid state between the first setter and the last, and anyone holding a reference can see it or change it later. Builder separates the two lives: a mutable builder while you are assembling, and an immutable, validated product afterwards. That sentence is the answer they are looking for.
times(quantity: number): thisprivate note?: stringreadonly extras: readonly string[][...this.extras]{ dish, quantity = 1 }: OrderOptions{ ... } as constquantity?: numberCheckpoint
1. Why does every setter method on the builder return this?
2. Where should a rule like "at most 5 extras, and quantity at least 1" be checked?
3. In TypeScript, you need to create an object with one required and four optional fields, all known at the call site. What is the simplest good design?
Builder is for objects with many parts, most of them optional, that must be valid when they are finished. Instead of a long constructor where three booleans in a row can be swapped, I set each part through a named method that returns the builder, and then call build, which validates everything and returns an immutable object. The required parts go in the builder's constructor and build returns copies so the product does not share memory with the builder. In TypeScript, an options object with optional properties and defaults covers most of this with no extra class, so I start there. I move to a real builder when construction happens in stages, or when I want a fluent API, like a query builder or test data builders.
