JavaScript and TypeScript course Β· Module 5: Advanced JavaScript

The Builder Pattern in JavaScript

9 min read
In this lesson6

In Jurassic Park, creating a new dinosaur is a complex process. Dr. Henry Wu can't just dump all the parameters into one constructor - genetic sequence, diet, habitat, danger level, temperature requirements... dozens of parameters! Fortunately there's the Builder pattern - an elegant way to construct complex objects step by step.

The Problem: Too Many Parameters

Imagine a dinosaur constructor that takes eleven arguments in a rigid order and without any names:

1// Constructor with many parameters - unreadable and error-prone
2const dino = new Dinosaur(
3  "Rexy",          // name
4  "Tyrannosaurus",  // species
5  "carnivore",      // diet
6  8,                // age
7  12000,            // weight
8  "Sector A",       // habitat
9  10,               // dangerLevel
10  true,             // isActive
11  38,               // optimalTemp
12  null,             // mate
13  ["hunt", "roar"]  // abilities
14);
15// Which parameter is which? Easy to mix up the order!

We recognize the arguments by position only. Swapping the age with the danger level passes without an error, because both are numbers, and the mistake only comes to light in the enclosure. A bare true or null in the middle of the list tells the person reading the code nothing.

The Builder Pattern - Solution

Builder lets you construct an object step by step with a readable API based on method chaining. The required data, the name and species, go to the constructor, and every optional trait is set by a separate method with a clear name:

1class DinosaurBuilder {
2  constructor(name, species) {
3    // Required parameters in constructor
4    this.dinosaur = {
5      name,
6      species,
7      abilities: []
8    };
9  }
10
11  // Each method sets a property and returns this (the builder)
12  setDiet(diet) {
13    this.dinosaur.diet = diet;
14    return this; // Critical! Returns the builder, enabling chaining
15  }
16
17  setAge(age) {
18    this.dinosaur.age = age;
19    return this;
20  }
21
22  setWeight(weight) {
23    this.dinosaur.weight = weight;
24    return this;
25  }
26
27  setHabitat(habitat) {
28    this.dinosaur.habitat = habitat;
29    return this;
30  }
31
32  setDangerLevel(level) {
33    this.dinosaur.dangerLevel = level;
34    return this;
35  }
36
37  setActive(isActive) {
38    this.dinosaur.isActive = isActive;
39    return this;
40  }
41
42  setOptimalTemperature(temp) {
43    this.dinosaur.optimalTemp = temp;
44    return this;
45  }
46
47  addAbility(ability) {
48    this.dinosaur.abilities.push(ability);
49    return this;
50  }
51
52  // build() creates the final object
53  build() {
54    // Validation before building
55    if (!this.dinosaur.diet) {
56      throw new Error("Diet is required!");
57    }
58    return { ...this.dinosaur };
59  }
60}

The heart of the pattern is the return this line: each method changes one property and hands back the same builder, so you can call the next method on the result right away. The build() method finishes the job - it validates the data first and then returns a copy of the collected properties.

This is what usage looks like. Every link of the chain says what it sets, so the code reads like a lab protocol:

1// Usage - readable and self-documenting
2const rexy = new DinosaurBuilder("Rexy", "Tyrannosaurus")
3  .setDiet("carnivore")
4  .setAge(8)
5  .setWeight(12000)
6  .setHabitat("Sector A")
7  .setDangerLevel(10)
8  .setActive(true)
9  .setOptimalTemperature(38)
10  .addAbility("hunt")
11  .addAbility("roar")
12  .build();
13
14console.log(rexy);
15// { name: "Rexy", species: "Tyrannosaurus", abilities: ["hunt", "roar"],
16//   diet: "carnivore", age: 8, weight: 12000, habitat: "Sector A",
17//   dangerLevel: 10, isActive: true, optimalTemp: 38 }

new DinosaurBuilder(...) always comes first, then the setter methods in any order, and build() at the very end, because it returns the finished object, not the builder. Note: the spread in build() makes a shallow copy, so the abilities array is shared between the builder and the finished dinosaur.

Fluent API - Method Chaining in Practice

The key to the Builder pattern is the Fluent API - each method returns this, allowing calls to be chained. The same trick works great for queries to the dinosaur database. First, the query builder itself:

1// Fluent API for building dinosaur queries
2class DinoQueryBuilder {
3  constructor() {
4    this.query = {
5      filters: {},
6      sort: null,
7      limit: 100,
8      offset: 0
9    };
10  }
11
12  whereSpecies(species) {
13    this.query.filters.species = species;
14    return this;
15  }
16
17  whereDiet(diet) {
18    this.query.filters.diet = diet;
19    return this;
20  }
21
22  whereDangerAbove(level) {
23    this.query.filters.minDanger = level;
24    return this;
25  }
26
27  whereActive(isActive = true) {
28    this.query.filters.isActive = isActive;
29    return this;
30  }
31
32  sortBy(field, direction = "asc") {
33    this.query.sort = { field, direction };
34    return this;
35  }
36
37  limitTo(count) {
38    this.query.limit = count;
39    return this;
40  }
41
42  skip(count) {
43    this.query.offset = count;
44    return this;
45  }
46
47  build() {
48    return { ...this.query };
49  }
50
51  // Simulated query execution
52  execute(database) {
53    const query = this.build();
54    console.log("Executing query:", JSON.stringify(query, null, 2));
55
56    let results = [...database];
57
58    // Filtering
59    if (query.filters.species) {
60      results = results.filter(d => d.species === query.filters.species);
61    }
62    if (query.filters.diet) {
63      results = results.filter(d => d.diet === query.filters.diet);
64    }
65    if (query.filters.minDanger) {
66      results = results.filter(d => d.dangerLevel >= query.filters.minDanger);
67    }
68    if (query.filters.isActive !== undefined) {
69      results = results.filter(d => d.isActive === query.filters.isActive);
70    }
71
72    // Sorting
73    if (query.sort) {
74      results.sort((a, b) => {
75        const dir = query.sort.direction === "asc" ? 1 : -1;
76        if (a[query.sort.field] === b[query.sort.field]) return 0; // equal values
77        return a[query.sort.field] > b[query.sort.field] ? dir : -dir;
78      });
79    }
80
81    // Pagination
82    results = results.slice(query.offset, query.offset + query.limit);
83
84    return results;
85  }
86}

The where... methods only record filters in the query object - nothing is filtered yet. Only execute() runs a copy of the database through filter(), sorts it and trims it with slice(), while the original array stays untouched. The compare function returns 0 for equal values, because sort() requires a consistent comparison.

Now a sample database and a query made of five readable steps:

1// Sample database
2const dinoDatabase = [
3  { name: "Rexy", species: "Tyrannosaurus", diet: "carnivore", dangerLevel: 10, isActive: true },
4  { name: "Blue", species: "Velociraptor", diet: "carnivore", dangerLevel: 8, isActive: true },
5  { name: "Spike", species: "Triceratops", diet: "herbivore", dangerLevel: 4, isActive: true },
6  { name: "Echo", species: "Velociraptor", diet: "carnivore", dangerLevel: 7, isActive: false },
7  { name: "Trike", species: "Triceratops", diet: "herbivore", dangerLevel: 3, isActive: true }
8];
9
10// Building a query with Fluent API
11const dangerousCarnivores = new DinoQueryBuilder()
12  .whereDiet("carnivore")
13  .whereDangerAbove(7)
14  .whereActive()
15  .sortBy("dangerLevel", "desc")
16  .limitTo(5)
17  .execute(dinoDatabase);
18
19console.log("Dangerous carnivores:", dangerousCarnivores);
20// [{ name: "Rexy", ... }, { name: "Blue", ... }]

Out of five dinosaurs, Rexy and Blue remain: the herbivores fail the diet filter, and Echo fails the activity filter. You will find the same style in many libraries that build database queries.

Builder with Validation and Default Values

A builder can also enforce the park's safety rules. The constructor sets sensible defaults, and the methods check the data and record errors instead of stopping the build halfway:

1class EnclosureBuilder {
2  constructor(name) {
3    this.enclosure = {
4      name,
5      type: "standard",
6      fenceVoltage: 10000,
7      size: 1000,
8      features: [],
9      maxCapacity: 5,
10      currentDinosaurs: []
11    };
12    this.errors = [];
13  }
14
15  setType(type) {
16    const validTypes = ["standard", "aquatic", "aviary", "high-security"];
17    if (!validTypes.includes(type)) {
18      this.errors.push(`Invalid enclosure type: ${type}`);
19    }
20    this.enclosure.type = type;
21    return this;
22  }
23
24  setFenceVoltage(voltage) {
25    if (voltage < 5000) {
26      this.errors.push("Fence voltage must be >= 5000V");
27    }
28    this.enclosure.fenceVoltage = voltage;
29    return this;
30  }
31
32  setSize(squareMeters) {
33    if (squareMeters < 100) {
34      this.errors.push("Minimum area is 100 mΒ²");
35    }
36    this.enclosure.size = squareMeters;
37    return this;
38  }
39
40  setMaxCapacity(capacity) {
41    this.enclosure.maxCapacity = capacity;
42    return this;
43  }
44
45  addFeature(feature) {
46    this.enclosure.features.push(feature);
47    return this;
48  }
49
50  addDinosaur(dinosaur) {
51    if (this.enclosure.currentDinosaurs.length >= this.enclosure.maxCapacity) {
52      this.errors.push(`Enclosure full! Max: ${this.enclosure.maxCapacity}`);
53    }
54    this.enclosure.currentDinosaurs.push(dinosaur);
55    return this;
56  }
57
58  build() {
59    if (this.errors.length > 0) {
60      throw new Error(
61        `Enclosure build errors:\n${this.errors.join("\n")}`
62      );
63    }
64    return Object.freeze({ ...this.enclosure }); // Frozen object
65  }
66}

Errors pile up in the errors array, so build() reports all the problems at once, not just the first one. Object.freeze() freezes the finished enclosure, but only at the top level - you can still add something to the features array.

Building an enclosure for the raptors passes validation, because the voltage, area and number of residents are within limits:

1// Building an enclosure
2const raptorPaddock = new EnclosureBuilder("Raptor Paddock")
3  .setType("high-security")
4  .setFenceVoltage(25000)
5  .setSize(5000)
6  .setMaxCapacity(4)
7  .addFeature("electrified-fence")
8  .addFeature("motion-sensors")
9  .addFeature("reinforced-gates")
10  .addDinosaur("Blue")
11  .addDinosaur("Charlie")
12  .addDinosaur("Delta")
13  .build();
14
15console.log(raptorPaddock);
16// { name: "Raptor Paddock", type: "high-security", fenceVoltage: 25000,
17//   size: 5000, features: [...], maxCapacity: 4, currentDinosaurs: [...] }

If someone set the voltage to 3000 V, build() would throw an exception with the list of errors and no enclosure would be created. Fields you don't set keep the default values from the constructor.

Functional Builder (Without Classes)

Builder doesn't require classes - we can use simple functions. The report's state lives in a closure, and the methods return the builder object instead of this:

1function createDinoReport(name) {
2  const report = { name, sections: [] };
3
4  const builder = {
5    addVitals(heartRate, temperature) {
6      report.sections.push({ type: "vitals", heartRate, temperature });
7      return builder;
8    },
9
10    addBehavior(description, threatLevel) {
11      report.sections.push({ type: "behavior", description, threatLevel });
12      return builder;
13    },
14
15    addNote(text) {
16      report.sections.push({
17        type: "note",
18        text,
19        timestamp: new Date().toISOString()
20      });
21      return builder;
22    },
23
24    build() {
25      return {
26        ...report,
27        generatedAt: new Date().toISOString(),
28        totalSections: report.sections.length
29      };
30    }
31  };
32
33  return builder;
34}

The report variable cannot be reached from outside, so you can change it only through the builder's methods. Returning builder instead of this means a method still works even when someone takes it out of the object and calls it on its own.

We assemble the veterinary report from five entries and one build() call:

1// Building a report
2const report = createDinoReport("Rexy")
3  .addVitals(65, 38.2)
4  .addBehavior("Calm, eating regularly", "low")
5  .addNote("New hunting pattern observed")
6  .addVitals(120, 39.1)
7  .addBehavior("Agitated after the storm", "medium")
8  .build();
9
10console.log(JSON.stringify(report, null, 2));

The build() method adds a generation timestamp and the number of sections, 5 here, and JSON.stringify(report, null, 2) prints the result with indentation.

Summary

Dr. Wu summarizes: "The Builder pattern is like the protocol for creating a dinosaur - step by step, with validation at each stage:"

  1. Builder - constructs complex objects step by step instead of one massive constructor
  2. Fluent API / Method chaining - each method returns this, enabling chained calls
  3. Validation - build() checks correctness before creating the object
  4. Readability - code is self-documenting, each parameter has a named method

The Builder pattern is especially useful when:

  • An object has many optional parameters
  • You want to validate data before creating the object
  • You're building queries, configurations, or complex data structures

My advice: reach for the Builder pattern when there are many optional fields. With two or three, a constructor that takes a single options object, such as new Dinosaur({ name, diet }), is enough, because the field names document the code themselves. In the next lesson you will create custom error classes, so that build() can report a problem more precisely than a plain Error. In the lab below you will build a ReportBuilder based on method chaining.

Remember: Builder is the hatchery protocol - a dinosaur is created step by step and comes out into the world only after passing inspection in build().

Code for this lesson: index.js
1// The Builder pattern in JavaScript
2// Jurassic Park - Creating complex objects
3
4console.log("=== BUILDER PATTERN ===");
5
6// A builder for dinosaurs
7class DinosaurBuilder {
8  constructor(name, species) {
9    this.dinosaur = { name, species, abilities: [] };
10  }
11
12  setDiet(diet) {
13    this.dinosaur.diet = diet;
14    return this; // Returns this - enables chaining
15  }
16
17  setAge(age) {
18    this.dinosaur.age = age;
19    return this;
20  }
21
22  setWeight(weight) {
23    this.dinosaur.weight = weight;
24    return this;
25  }
26
27  setHabitat(habitat) {
28    this.dinosaur.habitat = habitat;
29    return this;
30  }
31
32  setDangerLevel(level) {
33    this.dinosaur.dangerLevel = level;
34    return this;
35  }
36
37  addAbility(ability) {
38    this.dinosaur.abilities.push(ability);
39    return this;
40  }
41
42  build() {
43    if (!this.dinosaur.diet) {
44      throw new Error("A diet is required!");
45    }
46    return { ...this.dinosaur };
47  }
48}
49
50// Creating a dinosaur with method chaining
51const rexy = new DinosaurBuilder("Rexy", "Tyrannosaurus")
52  .setDiet("carnivore")
53  .setAge(8)
54  .setWeight(12000)
55  .setHabitat("Sector A")
56  .setDangerLevel(10)
57  .addAbility("hunt")
58  .addAbility("roar")
59  .build();
60
61console.log("Rexy:", rexy);
62
63const blue = new DinosaurBuilder("Blue", "Velociraptor")
64  .setDiet("carnivore")
65  .setAge(4)
66  .setWeight(80)
67  .setDangerLevel(8)
68  .addAbility("hunt")
69  .addAbility("communicate")
70  .addAbility("coordinate-pack")
71  .build();
72
73console.log("Blue:", blue);
74
75// === FLUENT QUERY BUILDER ===
76console.log("\n=== QUERY BUILDER ===");
77
78class DinoQueryBuilder {
79  constructor() {
80    this.query = { filters: {}, sort: null, limit: 100 };
81  }
82
83  whereDiet(diet) {
84    this.query.filters.diet = diet;
85    return this;
86  }
87
88  whereDangerAbove(level) {
89    this.query.filters.minDanger = level;
90    return this;
91  }
92
93  sortBy(field, direction = "asc") {
94    this.query.sort = { field, direction };
95    return this;
96  }
97
98  limitTo(count) {
99    this.query.limit = count;
100    return this;
101  }
102
103  execute(database) {
104    let results = [...database];
105    const f = this.query.filters;
106    if (f.diet) results = results.filter(d => d.diet === f.diet);
107    if (f.minDanger) results = results.filter(d => d.dangerLevel >= f.minDanger);
108    if (this.query.sort) {
109      const dir = this.query.sort.direction === "asc" ? 1 : -1;
110      results.sort((a, b) =>
111        a[this.query.sort.field] > b[this.query.sort.field] ? dir : -dir
112      );
113    }
114    return results.slice(0, this.query.limit);
115  }
116}
117
118const db = [
119  { name: "Rexy", diet: "carnivore", dangerLevel: 10 },
120  { name: "Blue", diet: "carnivore", dangerLevel: 8 },
121  { name: "Spike", diet: "herbivore", dangerLevel: 4 },
122  { name: "Echo", diet: "carnivore", dangerLevel: 7 },
123  { name: "Trike", diet: "herbivore", dangerLevel: 3 },
124];
125
126const dangerous = new DinoQueryBuilder()
127  .whereDiet("carnivore")
128  .whereDangerAbove(7)
129  .sortBy("dangerLevel", "desc")
130  .limitTo(5)
131  .execute(db);
132
133console.log("Dangerous carnivores:", dangerous);
134
135// === BUILDER WITH VALIDATION ===
136console.log("\n=== BUILDER WITH VALIDATION ===");
137
138class EnclosureBuilder {
139  constructor(name) {
140    this.enclosure = { name, type: "standard", features: [], dinosaurs: [] };
141    this.errors = [];
142  }
143
144  setType(type) {
145    const valid = ["standard", "aquatic", "aviary", "high-security"];
146    if (!valid.includes(type)) this.errors.push("Invalid type: " + type);
147    this.enclosure.type = type;
148    return this;
149  }
150
151  addFeature(feature) {
152    this.enclosure.features.push(feature);
153    return this;
154  }
155
156  addDinosaur(name) {
157    this.enclosure.dinosaurs.push(name);
158    return this;
159  }
160
161  build() {
162    if (this.errors.length > 0) {
163      throw new Error("Errors: " + this.errors.join(", "));
164    }
165    return Object.freeze({ ...this.enclosure });
166  }
167}
168
169const paddock = new EnclosureBuilder("Raptor Paddock")
170  .setType("high-security")
171  .addFeature("electrified-fence")
172  .addFeature("motion-sensors")
173  .addDinosaur("Blue")
174  .addDinosaur("Charlie")
175  .addDinosaur("Delta")
176  .build();
177
178console.log("Paddock:", paddock);
179
180// TODO: Create a ReportBuilder with method chaining
181// addVitals(), addBehavior(), addNote(), build()
182console.log("\n=== Exercise ===");
183console.log("Create the ReportBuilder - see the TODO in the code");

Spotted a mistake in this lesson?

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the elements for creating an object with the Builder pattern:

  • Click in order

    Arrange the elements of a Builder method that enables chaining:

Useful articles