JavaScript and TypeScript course Β· Module 5: Advanced JavaScript
The Builder Pattern in JavaScript
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:"
- Builder - constructs complex objects step by step instead of one massive constructor
- Fluent API / Method chaining - each method returns
this, enabling chained calls - Validation -
build()checks correctness before creating the object - 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: