Cours JavaScript et TypeScript · Module 5: JavaScript avancé
Le motif Builder en JavaScript
Dans cette leçon6
À Jurassic Park, créer un nouveau dinosaure est un processus complexe. Le Dr Henry Wu ne peut pas simplement jeter tous les paramètres dans un seul constructeur - séquence génétique, régime, habitat, niveau de danger, exigences de température... Cela fait des dizaines de paramètres ! Heureusement, il existe le motif Builder - une façon élégante de construire des objets complexes étape par étape.
Le problème : trop de paramètres
Imagine un constructeur de dinosaure qui prend onze arguments dans un ordre rigide et sans aucun nom :
1// Constructeur avec de nombreux paramètres - illisible et source d'erreurs
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// Quel paramètre correspond à quoi ? Facile de se tromper d'ordre !On reconnaît les arguments uniquement à leur position. Inverser l'âge et le niveau de danger passera sans erreur, car ce sont deux nombres, et la méprise ne se révélera que dans l'enclos. Un simple true ou null au milieu de la liste ne dit rien à la personne qui lit le code.
Le motif Builder - la solution
Le Builder permet de construire un objet étape par étape, avec une API lisible fondée sur l'enchaînement d'appels (method chaining). Les données obligatoires, le nom et l'espèce, vont dans le constructeur, et chaque caractéristique facultative est définie par une méthode séparée au nom explicite :
1class DinosaurBuilder {
2 constructor(name, species) {
3 // Paramètres obligatoires dans le constructeur
4 this.dinosaur = {
5 name,
6 species,
7 abilities: []
8 };
9 }
10
11 // Chaque méthode définit une propriété et renvoie this (le builder)
12 setDiet(diet) {
13 this.dinosaur.diet = diet;
14 return this; // Essentiel ! Renvoie le builder, ce qui permet le 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 // La méthode build() crée l'objet final
53 build() {
54 // Validation avant la construction
55 if (!this.dinosaur.diet) {
56 throw new Error("Le régime est obligatoire !");
57 }
58 return { ...this.dinosaur };
59 }
60}Le cœur du motif est la ligne return this : chaque méthode modifie une propriété et rend le même builder, on peut donc appeler immédiatement la méthode suivante sur le résultat. La méthode build() termine la construction - elle vérifie d'abord les données, puis renvoie une copie des propriétés rassemblées.
Voici l'utilisation. Chaque maillon de la chaîne dit ce qu'il définit, le code se lit donc comme un protocole de laboratoire :
1// Utilisation - lisible et auto-documentée
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 }Au début se trouve toujours new DinosaurBuilder(...), puis les méthodes de réglage dans n'importe quel ordre, et build() tout à la fin, car elle renvoie l'objet fini, et non le builder. Attention : le spread dans build() fait une copie superficielle, le tableau abilities est donc partagé entre le builder et le dinosaure terminé.
La Fluent API - le method chaining en pratique
La clé du motif Builder est la Fluent API - chaque méthode renvoie this, ce qui permet d'enchaîner les appels. Cette même astuce fonctionne à merveille pour les requêtes sur la base de dinosaures. D'abord, le builder de requêtes lui-même :
1// Fluent API pour construire des requêtes sur les dinosaures
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 // Simulation de l'exécution de la requête
52 execute(database) {
53 const query = this.build();
54 console.log("Exécution de la requête :", JSON.stringify(query, null, 2));
55
56 let results = [...database];
57
58 // Filtrage
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 // Tri
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; // valeurs égales
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}Les méthodes where... se contentent d'enregistrer les filtres dans l'objet query - rien n'est encore filtré. C'est seulement execute() qui fait passer une copie de la base par filter(), la trie et la découpe avec slice(), tandis que le tableau d'origine reste intact. La fonction de comparaison renvoie 0 pour des valeurs égales, car sort() exige une comparaison cohérente.
Voici maintenant une base d'exemple et une requête composée de cinq étapes lisibles :
1// Base de données d'exemple
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// Construire une requête avec la 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("Carnivores dangereux :", dangerousCarnivores);
20// [{ name: "Rexy", ... }, { name: "Blue", ... }]Des cinq dinosaures, il reste Rexy et Blue : les herbivores sont éliminés par le filtre du régime, et Echo par le filtre d'activité. Tu retrouveras ce même style dans de nombreuses bibliothèques qui construisent des requêtes vers des bases de données.
Un Builder avec validation et valeurs par défaut
Un Builder peut aussi faire respecter les règles de sécurité du parc. Le constructeur définit des valeurs par défaut raisonnables, et les méthodes vérifient les données et enregistrent les erreurs au lieu d'interrompre la construction en cours de route :
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(`Type d'enclos invalide : ${type}`);
19 }
20 this.enclosure.type = type;
21 return this;
22 }
23
24 setFenceVoltage(voltage) {
25 if (voltage < 5000) {
26 this.errors.push("La tension de la clôture doit être >= 5000V");
27 }
28 this.enclosure.fenceVoltage = voltage;
29 return this;
30 }
31
32 setSize(squareMeters) {
33 if (squareMeters < 100) {
34 this.errors.push("La surface minimale est de 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(`Enclos plein ! 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 `Erreurs de construction de l'enclos :\n${this.errors.join("\n")}`
62 );
63 }
64 return Object.freeze({ ...this.enclosure }); // Objet gelé
65 }
66}Les erreurs s'accumulent dans le tableau errors, donc build() signale tous les problèmes à la fois, et pas seulement le premier. Object.freeze() gèle l'enclos terminé, mais seulement au premier niveau - on peut encore ajouter quelque chose au tableau features.
La construction d'un enclos pour les raptors passe la validation, car la tension, la surface et le nombre d'habitants respectent les limites :
1// Construire un enclos
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: [...] }Si quelqu'un réglait la tension sur 3000 V, build() lèverait une exception avec la liste des erreurs, et aucun enclos ne serait créé. Les champs que tu ne définis pas gardent les valeurs par défaut du constructeur.
Un Builder avec des fonctions (sans classes)
Le Builder n'exige pas de classes - on peut utiliser de simples fonctions. L'état du rapport vit dans une closure, et les méthodes renvoient l'objet builder au lieu de this :
1function createDinoReport(name) {
2 const report = { name, sections: [] };
3
4 const builder = {
5 addVitals(heartRate, temperature) {
6 report.sections.push({
7 type: "vitals",
8 heartRate,
9 temperature
10 });
11 return builder;
12 },
13
14 addBehavior(description, threatLevel) {
15 report.sections.push({
16 type: "behavior",
17 description,
18 threatLevel
19 });
20 return builder;
21 },
22
23 addNote(text) {
24 report.sections.push({
25 type: "note",
26 text,
27 timestamp: new Date().toISOString()
28 });
29 return builder;
30 },
31
32 build() {
33 return {
34 ...report,
35 generatedAt: new Date().toISOString(),
36 totalSections: report.sections.length
37 };
38 }
39 };
40
41 return builder;
42}La variable report est inaccessible de l'extérieur, tu ne peux donc la modifier qu'avec les méthodes du builder. Renvoyer builder au lieu de this permet à une méthode de fonctionner même si quelqu'un la sort de l'objet et l'appelle séparément.
Nous composons le rapport vétérinaire avec cinq entrées et un seul appel à build() :
1// Construire un rapport
2const report = createDinoReport("Rexy")
3 .addVitals(65, 38.2)
4 .addBehavior("Calme, mange régulièrement", "low")
5 .addNote("Nouveau schéma de chasse observé")
6 .addVitals(120, 39.1)
7 .addBehavior("Agité après la tempête", "medium")
8 .build();
9
10console.log(JSON.stringify(report, null, 2));La méthode build() ajoute la date de génération et le nombre de sections, ici 5, et JSON.stringify(report, null, 2) affiche le résultat avec des indentations.
Résumé
Le Dr Wu résume : "Le motif Builder, c'est comme le protocole de création d'un dinosaure - étape par étape, avec une validation à chaque stade :"
- Builder - construit des objets complexes étape par étape au lieu d'un unique constructeur gigantesque
- Fluent API / Method chaining - chaque méthode renvoie
this, ce qui permet des appels en chaîne - Validation -
build()vérifie la validité avant de créer l'objet - Lisibilité - le code est auto-documenté, chaque paramètre a sa méthode nommée
Le motif Builder est particulièrement utile quand :
- L'objet a de nombreux paramètres facultatifs
- Tu veux valider les données avant de créer l'objet
- Tu construis des requêtes, des configurations ou des structures de données compliquées
Mon conseil : réserve le motif Builder aux cas avec de nombreux champs facultatifs. Pour deux ou trois, un constructeur qui prend un seul objet d'options suffit, par exemple new Dinosaur({ name, diet }), car les noms des champs documentent eux-mêmes le code. Dans la prochaine leçon, tu créeras tes propres classes d'erreurs, grâce auxquelles build() signalera un problème plus précisément qu'avec un simple Error. Dans le laboratoire ci-dessous, tu construiras un ReportBuilder fondé sur l'enchaînement de méthodes.
Retiens bien : le Builder est le protocole de l'écloserie - le dinosaure naît étape par étape et ne sort dans le monde qu'après le contrôle de build().
Code de cette leçon : index.js
1// Patron Builder en JavaScript
2// Jurassic Park - Creation d'objets complexes
3
4console.log("=== PATRON BUILDER ===");
5
6// Builder pour les dinosaures
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; // Renvoie this - permet le chainage
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("Le regime alimentaire est obligatoire !");
45 }
46 return { ...this.dinosaur };
47 }
48}
49
50// Creation d'un dinosaure avec chainage de methodes
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("Carnivores dangereux :", dangerous);
134
135// === BUILDER AVEC VALIDATION ===
136console.log("\n=== BUILDER AVEC 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("Type invalide : " + 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("Erreurs : " + 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("Enclos :", paddock);
179
180// TODO : cree un ReportBuilder avec chainage de methodes
181// addVitals(), addBehavior(), addNote(), build()
182console.log("\n=== Exercice ===");
183console.log("Cree le ReportBuilder - voir le TODO dans le code");Tu as repéré une erreur dans cette leçon ?
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne les éléments de la création d'un objet avec le patron Builder :
- Clique dans l'ordre
Ordonne les éléments d'une méthode de Builder qui permet le chaînage :