Skip to content
wannacode

Builder

In short: Builder assembles a complex object step by step, with a name for every field, and validates it in build(); in JavaScript an options object often does the same.

Problem

Nine parameters and two swapped words

The shop sends an email when an order ships. The email has nine fields: who it goes to, the address for replies, the subject, the body, a copy, a blind copy, an attachment, whether it is important and whether opening it is tracked.

class Email {
constructor(to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens) {
this.to = to;
this.replyTo = replyTo;
this.subject = subject;
this.body = body;
this.cc = cc;
this.bcc = bcc;
this.attachment = attachment;
this.highPriority = highPriority;
this.trackOpens = trackOpens;
}
send() {
console.log(`To: ${this.to}`);
if (this.replyTo) {
console.log(`Reply to: ${this.replyTo}`);
}
console.log(`Subject: ${this.subject}`);
if (this.attachment) {
console.log(`Attachment: ${this.attachment}`);
}
if (this.highPriority) {
console.log("High priority");
}
}
}

This is how the confirmation for order 1042 is sent:

const email = new Email("support@shop.example", "maria@example.com", "Order 1042 has shipped",
"Your parcel is on its way.", null, null, "invoice-1042.pdf", true, false);
email.send();
Output
To: support@shop.example
Reply to: maria@example.com
Subject: Order 1042 has shipped
Attachment: invoice-1042.pdf
High priority

The email went to the shop’s support, and Maria got nothing. The first two arguments are swapped. Both are text, so the language has no way to tell. And which is true and which is false at the end? To find out, you count positions in the constructor. Two null hold the places of fields this email doesn’t have.

Every new field, say the language of the email, changes the constructor and every place that calls it.

The idea

Builder: by name, in steps, with a check

The object isn’t made with one call of nine positions, but in steps, each with a name: .to(…), .subject(…). The steps go to a separate object, the builder. It collects the values, and at the end build():

  • checks the required fields (here: recipient, subject and body) and throws an exception if one is missing;
  • makes an immutable object: after that the email can’t change.

This is the Builder pattern. The roles:

  • the product: Email, immutable;
  • the builder: EmailBuilder, with a method for every field. The method returns the builder itself, so the calls line up one after another in a chain;
  • build(): the only place where the product is checked and made.
In JavaScript the problem from the first card also has a shorter answer: an options object. It is in the card "Without a builder". The builder here is for the case when the email is put together in steps, in different places in the code.

See it

The chain and build()

class Email {
constructor({
to,
replyTo = null,
subject,
body,
cc = null,
bcc = null,
attachment = null,
highPriority = false,
trackOpens = false,
}) {
Object.assign(this, { to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens });
Object.freeze(this);
}
send() {
console.log(`To: ${this.to}`);
if (this.replyTo) {
console.log(`Reply to: ${this.replyTo}`);
}
console.log(`Subject: ${this.subject}`);
if (this.attachment) {
console.log(`Attachment: ${this.attachment}`);
}
if (this.highPriority) {
console.log("High priority");
}
}
}
class EmailBuilder {
fields = {};
to(value) {
this.fields.to = value;
return this;
}
replyTo(value) {
this.fields.replyTo = value;
return this;
}
subject(value) {
this.fields.subject = value;
return this;
}
body(value) {
this.fields.body = value;
return this;
}
cc(value) {
this.fields.cc = value;
return this;
}
bcc(value) {
this.fields.bcc = value;
return this;
}
attachment(value) {
this.fields.attachment = value;
return this;
}
highPriority() {
this.fields.highPriority = true;
return this;
}
trackOpens() {
this.fields.trackOpens = true;
return this;
}
build() {
if (this.fields.to === undefined) {
throw new Error("Missing recipient");
}
if (this.fields.subject === undefined) {
throw new Error("Missing subject");
}
if (this.fields.body === undefined) {
throw new Error("Missing body");
}
return new Email(this.fields);
}
}

A builder costs code: every field is written both in the email and in the builder’s method. That’s why it isn’t made for every class.

Email takes one options object and destructures it by name, with default values. Object.freeze(this) forbids changes after creation.
const email = new EmailBuilder()
.to("maria@example.com")
.subject("Order 1042 has shipped")
.body("Your parcel is on its way.")
.attachment("invoice-1042.pdf")
.highPriority()
.build();
email.send();
const draft = new EmailBuilder()
.to("peter@example.com")
.body("Your parcel is on its way.");
try {
draft.build();
} catch (e) {
console.log(e.message);
}
Output
To: maria@example.com
Subject: Order 1042 has shipped
Attachment: invoice-1042.pdf
High priority
Missing subject

const email = new EmailBuilder()
.to("maria@example.com")
.subject("Order 1042 has shipped")
.body("Your parcel is on its way.")
.attachment("invoice-1042.pdf")
.highPriority()
.build();
email.send();
const draft = new EmailBuilder()
.to("peter@example.com")
.body("Your parcel is on its way.");
try {
draft.build();
} catch (e) {
console.log(e.message);
}
Step by step, in words
  1. new EmailBuilder(): an empty blueprint with nine slots. The three required ones (to, subject, body) are marked.
  2. .to(…) fills one slot and returns the same builder, so the chain goes on.
  3. Every call has a name: a value cannot land in the wrong slot, as it did with the positions in the constructor.
  4. .body(…): all three required slots are filled.
  5. Optional fields are written only when needed. highPriority() takes no argument: no more true in eighth place.
  6. build() checks the required slots and stamps out an Email. Its fields are Object.freeze: the object doesn't change any more.
  7. The email goes to Maria, with the invoice, as high priority.
  8. A new builder, a new empty blueprint. Here someone forgot the subject.
  9. draft.build() reaches the empty slot subject and stops with an exception. An email without a subject never exists at all.
  10. The error says what is missing. Nothing was sent.

The email without a subject never reaches the mail: build() stops it already at assembly.

See it

Without a builder: what the language has

JavaScript does it with an options object: one parameter, destructured by name, with default values. The order of the fields doesn’t matter, and a missing field gets its default value:
class Email {
constructor({
to,
replyTo = null,
subject,
body,
cc = null,
bcc = null,
attachment = null,
highPriority = false,
trackOpens = false,
}) {
if (!to || !subject || !body) {
throw new Error("Missing recipient, subject or body");
}
Object.assign(this, { to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens });
Object.freeze(this);
}
}
const email = new Email({
to: "maria@example.com",
subject: "Order 1042 has shipped",
body: "Your parcel is on its way.",
attachment: "invoice-1042.pdf",
highPriority: true,
});
console.log(email.to, "/", email.subject, "/", email.highPriority);
Output
maria@example.com / Order 1042 has shipped / true
Object.freeze stops changes. In a module (a file with import) or with "use strict" the assignment throws an error, and in a plain script it silently does nothing:
email.to = "peter@example.com";
Error
TypeError: Cannot assign to read only property 'to' of object '#<Email>'

When a builder is still worth it:

  • the email is put together in steps, in different parts of the code: the attachment is added only if there is an invoice, the copy only for companies;
  • there is a check across fields: for example, the blind copy can’t be the recipient’s address;
  • there are many fields, most of them optional, and the assembly has to be passed from one place to another before it is ready.

Try it

Predict the output

One builder, two calls to build(). What does the program print?

const builder = new EmailBuilder()
.to("maria@example.com")
.subject("Order 1042 has shipped")
.body("Your parcel is on its way.");
const first = builder.build();
builder.highPriority();
const second = builder.build();
console.log(first.highPriority, second.highPriority);
Answer

Try it

Find the bug

The shop sends the emails for shipped orders in a loop. Only the first order has an invoice:

const orders = [
{ email: "maria@example.com", id: 1042, invoice: "invoice-1042.pdf" },
{ email: "peter@example.com", id: 1043, invoice: null },
];
const builder = new EmailBuilder().replyTo("support@shop.example");
for (const order of orders) {
builder
.to(order.email)
.subject(`Order ${order.id} has shipped`)
.body("Your parcel is on its way.");
if (order.invoice) {
builder.attachment(order.invoice);
}
builder.build().send();
}
Output
To: maria@example.com
Reply to: support@shop.example
Subject: Order 1042 has shipped
Attachment: invoice-1042.pdf
To: peter@example.com
Reply to: support@shop.example
Subject: Order 1043 has shipped
Attachment: invoice-1042.pdf

Peter got Maria’s invoice. Which line has the bug?

Pick the line with the bug.

The idea

When you don’t need it

Builder doubles the code of the class. It isn’t worth it when:

  • there are two or three fields and all are required: a plain constructor is clearer;
  • an options object does the same without a second class;
  • the object has nothing to check and isn’t put together in steps.

It is worth it when there are many fields and most are optional, when there is a check across them, or when the object is put together in steps, in different parts of the code.

Exercise

A parcel for the courier

Solve the task in your editor.

A builder for a parcel. The courier wants a parcel with a recipient, a phone, an address and a weight (required), and optionally: fragile and cash on delivery. Write Shipment and a builder for it. build() checks that the required fields are set and that the weight is over 0 and up to 30 kg. Make a parcel for Maria (2.5 kg, fragile, cash on delivery €42.90), print it, and try a 45 kg parcel:

Output
Parcel for Maria, 0888 123 456
Address: 5 Shipka St, Plovdiv
Weight: 2.5 kg, fragile
Cash on delivery: €42.90
The weight must be over 0 and up to 30 kg

More tasks

  • Fix the loop. Fix the program from "Find the bug" so that every email carries only its own invoice. The reply address must stay for all emails.
  • A copy with one change. Add a method that turns a finished email into a builder with the same values (in Java it is often called toBuilder()). Send the same email to another recipient.
  • The language’s way. Write Shipment as in the card "Without a builder" and compare the two versions. Which one is shorter, and what do you lose?

Test

Check yourself

1. new Email("support@…", "maria@…", …) runs without an error, and the email goes somewhere else. Why didn’t JavaScript help?
2. What does build() do?
3. The builder is made before the loop and used for every email. The second email got the attachment of the first one. Why?
4. When is Builder still worth it?
5. What in JavaScript solves the problem with the nine positional arguments?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·