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) // false

Quick, 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.

Your answer

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

The picture to keep
The chaat counter

“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.

In one line: Set the parts one named step at a time, then call build() to get a finished, valid object.

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.

ChoiceWhat you gainWhat you payPick it when
Options objectReadable 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 classThe 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.

The builder that hands out its insides

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.

Validation in the setters

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.

Why not just use setters on the object?

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.

TypeScript you just picked up
times(quantity: number): this
Returning this allows chaining. The this type keeps working in subclasses.
private note?: string
An optional field. It is string or undefined until someone sets it.
readonly extras: readonly string[]
The property cannot be reassigned, and the array cannot be changed either.
[...this.extras]
Spread into a new array. A copy the caller can keep without affecting the builder.
{ dish, quantity = 1 }: OrderOptions
Destructure a parameter, with a default for a missing property.
{ ... } as const
Marks every property of the returned object as readonly, so callers cannot reassign them.
quantity?: number
Optional in the options type. Callers may leave it out.

Checkpoint

Checkpoint

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?

Say this in 60 seconds

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.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.