Cours JavaScript et TypeScript · Module 12: Programmation fonctionnelle

Motifs PF : foncteurs et monades

7 min de lecture
Dans cette leçon4

Trouver la zone d'un dinosaure demande trois étapes : trouver le dinosaure, prendre son zoneId, trouver la zone. Chaque étape peut renvoyer undefined, et une seule vérification oubliée se termine, en pleine équipe de nuit, par l'erreur TypeError: Cannot read properties of undefined. Un code rempli de if protège contre ça, mais masque ce que l'on fait réellement.

Dans le laboratoire avancé de Jurassic Park, on n'est pas toujours sûr qu'un échantillon d'ADN existe, qu'une analyse a réussi ou que les données sont complètes. Au lieu de vérifier des conditions à chaque étape (if/else), on peut utiliser d'élégants motifs issus de la programmation fonctionnelle : les foncteurs et les monades. C'est comme des conteneurs intelligents pour échantillons, qui savent eux-mêmes comment gérer des données manquantes ou endommagées.

Les foncteurs - Des conteneurs avec mappage

Un foncteur est un objet (un conteneur) qui implémente la méthode map. Il permet d'appliquer des transformations à la valeur qui se trouve dans le conteneur, sans avoir à l'en sortir. Tu connais déjà un foncteur : le tableau, dont la méthode map transforme le contenu et renvoie un nouveau tableau. Le plus simple des foncteurs maison est une boîte qui contient une seule valeur :

1// Le foncteur le plus simple - Box (conteneur)
2class Box {
3  constructor(value) {
4    this.value = value;
5  }
6
7  map(fn) {
8    return new Box(fn(this.value));
9  }
10
11  toString() {
12    return 'Box(' + this.value + ')';
13  }
14}

map ne modifie pas this.value, il crée une nouvelle Box avec le résultat de la fonction - c'est le même principe d'immutabilité que dans la leçon précédente. Comme chaque map renvoie une boîte, les appels s'enchaînent :

1// Utilisation - une chaîne de transformations
2const result = new Box(8000)
3  .map((weight) => weight / 1000)      // Box(8)
4  .map((tons) => tons + ' tons')        // Box('8 tons')
5  .map((label) => 'Weight: ' + label);  // Box('Weight: 8 tons')
6
7console.log(result.value); // 'Weight: 8 tons'

Chaque étape reçoit la valeur déballée, et le résultat retourne dans la boîte. Ce n'est qu'à la fin qu'on va chercher result.value.

Maybe - Gérer les valeurs manquantes

La monade Maybe résout le problème des valeurs null/undefined. Au lieu de vérifier partout if (x !== null), on enveloppe la valeur dans un Maybe et on laisse la monade gérer les données manquantes. La méthode statique of crée le conteneur, isNothing vérifie s'il est vide, map fonctionne comme dans Box, mais saute le conteneur vide, flatMap accepte une fonction qui renvoie un autre Maybe, et getOrElse extrait la valeur ou renvoie une valeur par défaut :

1class Maybe {
2  constructor(value) {
3    this.value = value;
4  }
5
6  static of(value) {
7    return new Maybe(value);
8  }
9
10  isNothing() {
11    return this.value === null || this.value === undefined;
12  }
13
14  map(fn) {
15    return this.isNothing() ? this : Maybe.of(fn(this.value));
16  }
17
18  flatMap(fn) {
19    return this.isNothing() ? this : fn(this.value);
20  }
21
22  getOrElse(defaultValue) {
23    return this.isNothing() ? defaultValue : this.value;
24  }
25}

Toute la sécurité tient dans une seule condition : un Maybe vide répond à chaque map en se renvoyant lui-même, donc la fonction ne reçoit jamais undefined. Compare maintenant la recherche d'une zone sans Maybe et avec :

1// Sans Maybe - des if partout
2function getDinoZoneOld(park, dinoId) {
3  const dino = park.dinosaurs.find((d) => d.id === dinoId);
4  if (!dino) return 'Unknown';
5  const zone = park.zones.find((z) => z.id === dino.zoneId);
6  if (!zone) return 'Unknown';
7  return zone.name;
8}
9
10// Avec Maybe - une chaîne élégante
11function getDinoZone(park, dinoId) {
12  return Maybe.of(park.dinosaurs.find((d) => d.id === dinoId))
13    .map((dino) => dino.zoneId)
14    .flatMap((zoneId) => Maybe.of(park.zones.find((z) => z.id === zoneId)))
15    .map((zone) => zone.name)
16    .getOrElse('Unknown');
17}

Les if ont disparu, et le cas « introuvable » est géré par un seul getOrElse à la fin. Pour la zone, on utilise flatMap, car la fonction de cette étape renvoie elle-même un Maybe - un map ordinaire donnerait un Maybe dans un Maybe. L'ordre est toujours le même : enveloppe avec Maybe.of, transforme avec map, enchaîne les étapes qui renvoient un Maybe avec flatMap, et extrais le résultat avec getOrElse. La chaîne la plus courte est Maybe.of(data).map((d) => d.name).getOrElse('Unknown').

Tu connais ce même duo grâce aux tableaux. flatMap commence par mapper, puis aplatit le résultat d'un niveau :

1const zoneIds = ['A', 'B'];
2const dinosByZone = { A: ['Rex', 'Blue'], B: ['Brachio'] };
3
4zoneIds.map((id) => dinosByZone[id]);     // [['Rex', 'Blue'], ['Brachio']]
5zoneIds.flatMap((id) => dinosByZone[id]); // ['Rex', 'Blue', 'Brachio']

map renvoie un tableau de tableaux, et flatMap une seule liste plate. Maybe.flatMap fait la même chose avec des boîtes. Pour une simple lecture de champs, l'écriture intégrée dino?.zone ?? 'Unknown' suffit, et Maybe devient rentable quand les étapes sont des recherches complètes.

Either - Gérer les erreurs

Maybe dit seulement que la valeur manque, mais pas pourquoi. Either représente une valeur qui peut être un succès (Right) ou un échec (Left). C'est l'alternative fonctionnelle à try/catch :

1class Left {
2  constructor(value) { this.value = value; }
3  map(fn) { return this; }           // Ignore les transformations
4  flatMap(fn) { return this; }
5  getOrElse(defaultValue) { return defaultValue; }
6  isLeft() { return true; }
7  isRight() { return false; }
8}
9
10class Right {
11  constructor(value) { this.value = value; }
12  map(fn) { return new Right(fn(this.value)); }
13  flatMap(fn) { return fn(this.value); }
14  getOrElse(defaultValue) { return this.value; }
15  isLeft() { return false; }
16  isRight() { return true; }
17}

Les deux classes ont la même API, mais Right.map applique la fonction, alors que Left.map la saute et transporte l'erreur plus loin. Après le premier échec, le reste de la chaîne ne s'exécute pas. Voyons cela avec la validation d'un échantillon :

1// Utilisation dans la validation d'un échantillon d'ADN
2function validateSample(sample) {
3  if (!sample.id) return new Left('Missing sample ID');
4  if (sample.purity < 0.5) return new Left('Purity too low: ' + sample.purity);
5  if (!sample.species) return new Left('Unknown species');
6  return new Right(sample);
7}
8
9function processSample(sample) {
10  return validateSample(sample)
11    .map((s) => ({ ...s, processed: true }))
12    .map((s) => ({ ...s, label: s.species + '-' + s.id }));
13}
14
15// Succès
16const good = processSample({ id: 'S001', purity: 0.95, species: 'T-Rex' });
17good.getOrElse('Error'); // { id: 'S001', purity: 0.95, species: 'T-Rex', processed: true, label: 'T-Rex-S001' }
18
19// Échec
20const bad = processSample({ id: 'S002', purity: 0.3, species: 'Raptor' });
21bad.getOrElse('Error'); // 'Error' (erreur : 'Purity too low: 0.3')

L'échantillon S001 passe les deux transformations. Pour S002, les deux map sont sautés, mais le message ne se perd pas : il se trouve dans bad.value, et bad.isLeft() renvoie true. Either n'attrape pourtant pas les exceptions tout seul, alors garde try/catch à la frontière avec le code qui en lève, comme JSON.parse.

Comment tout cela s'articule

Dans le jargon de la PF, une monade est un foncteur qui possède aussi flatMap (souvent appelé chain dans les bibliothèques) et un moyen de placer une valeur dans le conteneur, comme of. Les piliers de ce module se construisent depuis la base : les fonctions pures, l'immutabilité, les fonctions d'ordre supérieur, la composition avec le currying, et au sommet, les foncteurs et les monades. C'est dans ce même ordre que tu refactorises du code impératif : tu repères les effets de bord, tu extrais des fonctions pures, tu remplaces les boucles par map, filter et reduce, tu enchaînes les étapes avec pipe, et tu enveloppes les endroits risqués dans Maybe ou Either. Un pipeline de données a des couches similaires : le parsing, la validation avec Maybe ou Either, la transformation avec map, l'agrégation avec reduce et le rapport - tu le construiras dans le projet final. N'enveloppe pas tout dans des conteneurs pour autant : ils sont rentables à la frontière avec les données venues de l'extérieur.

Un foncteur et une monade ne sont rien d'autre qu'un conteneur à échantillon qui sait lui-même quoi faire d'un contenu manquant ou endommagé : c'est grâce à cela que Maybe et Either mènent la chaîne de transformations jusqu'au bout, au lieu d'ensevelir ton code sous des dizaines de vérifications et de blocs try/catch.

Code de cette leçon : index.js
1// Motifs FP : foncteurs et monades
2console.log("=== Jurassic Park - Conteneurs Intelligents d'ADN ===\n");
3
4// --- EXEMPLE 1 : Foncteur Box ---
5console.log("--- Foncteur Box ---");
6
7class Box {
8  constructor(value) { this.value = value; }
9  map(fn) { return new Box(fn(this.value)); }
10  toString() { return "Box(" + JSON.stringify(this.value) + ")"; }
11}
12
13const result = new Box(8000)
14  .map((w) => w / 1000)
15  .map((t) => t + " tons")
16  .map((l) => "Weight: " + l);
17
18console.log(result.toString()); // Box("Weight: 8 tons")
19
20// --- EXEMPLE 2 : Maybe ---
21console.log("\n--- Monade Maybe ---");
22
23class Maybe {
24  constructor(value) { this.value = value; }
25  static of(v) { return new Maybe(v); }
26  isNothing() { return this.value === null || this.value === undefined; }
27  map(fn) { return this.isNothing() ? this : Maybe.of(fn(this.value)); }
28  flatMap(fn) { return this.isNothing() ? this : fn(this.value); }
29  getOrElse(def) { return this.isNothing() ? def : this.value; }
30  toString() { return this.isNothing() ? "Maybe(Nothing)" : "Maybe(" + JSON.stringify(this.value) + ")"; }
31}
32
33const park = {
34  dinosaurs: [
35    { id: "D01", name: "Rex", zoneId: "Z01" },
36    { id: "D02", name: "Blue", zoneId: "Z02" },
37  ],
38  zones: [
39    { id: "Z01", name: "Zone A" },
40    { id: "Z02", name: "Zone B" },
41  ],
42};
43
44function getDinoZone(parkData, dinoId) {
45  return Maybe.of(parkData.dinosaurs.find((d) => d.id === dinoId))
46    .map((dino) => dino.zoneId)
47    .flatMap((zoneId) => Maybe.of(parkData.zones.find((z) => z.id === zoneId)))
48    .map((zone) => zone.name)
49    .getOrElse("Unknown");
50}
51
52console.log("Rex zone:", getDinoZone(park, "D01"));    // Zone A
53console.log("Blue zone:", getDinoZone(park, "D02"));   // Zone B
54console.log("Unknown:", getDinoZone(park, "D99"));     // Unknown
55
56// --- EXEMPLE 3 : Either ---
57console.log("\n--- Monade Either ---");
58
59class Left {
60  constructor(value) { this.value = value; }
61  map(fn) { return this; }
62  flatMap(fn) { return this; }
63  getOrElse(def) { return def; }
64  isLeft() { return true; }
65  isRight() { return false; }
66  toString() { return "Left(" + this.value + ")"; }
67}
68
69class Right {
70  constructor(value) { this.value = value; }
71  map(fn) { return new Right(fn(this.value)); }
72  flatMap(fn) { return fn(this.value); }
73  getOrElse(def) { return this.value; }
74  isLeft() { return false; }
75  isRight() { return true; }
76  toString() { return "Right(" + JSON.stringify(this.value) + ")"; }
77}
78
79function validateSample(sample) {
80  if (!sample.id) return new Left("Missing sample ID");
81  if (sample.purity < 0.5) return new Left("Purity too low: " + sample.purity);
82  if (!sample.species) return new Left("Unknown species");
83  return new Right(sample);
84}
85
86function processSample(sample) {
87  return validateSample(sample)
88    .map((s) => ({ ...s, processed: true }))
89    .map((s) => ({ ...s, label: s.species + "-" + s.id }));
90}
91
92const good = processSample({ id: "S01", purity: 0.95, species: "T-Rex" });
93console.log("Success:", good.toString());
94console.log("Value:", JSON.stringify(good.getOrElse("ERROR")));
95
96const bad1 = processSample({ id: "", purity: 0.95, species: "T-Rex" });
97console.log("\nFail 1:", bad1.toString());
98
99const bad2 = processSample({ id: "S02", purity: 0.3, species: "Raptor" });
100console.log("Fail 2:", bad2.toString());
101
102// --- EXEMPLE 4 : Chaine Either ---
103console.log("\n--- Chaine Either ---");
104
105function parseInput(raw) {
106  try { return new Right(JSON.parse(raw)); }
107  catch (e) { return new Left("Parse error: " + e.message); }
108}
109
110const pipeline = (raw) =>
111  parseInput(raw)
112    .flatMap((data) => data.name ? new Right(data) : new Left("Missing name"))
113    .map((data) => ({ ...data, status: "validated" }));
114
115console.log(pipeline('{"name":"Rex"}').toString());
116console.log(pipeline('{"age":5}').toString());
117console.log(pipeline('invalid json').toString());

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Que doit implémenter un objet pour être un foncteur ?

  2. 2. Que renvoie Array.flatMap() comparé à Array.map() ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les étapes pour récupérer des données en toute sécurité à l'aide de Maybe :

  • Tri horizontal

    Ordonne les éléments d'un appel flatMap sur Maybe :

  • Tri horizontal

    Ordonne les éléments d'une chaîne Maybe pour extraire des données en toute sécurité :

  • Clique dans l'ordre

    Ordonne les éléments d'une validation avec Either :

  • Tri vertical

    Ordonne les piliers de la programmation fonctionnelle du plus fondamental au plus avancé :

  • Éditeur de code

    Implémente une recherche de données sûre et une validation à l'aide de Maybe et Either.

  • Tri vertical

    Ordonne les éléments d'une implémentation de memoize avec fermeture :

  • Clique dans l'ordre

    Ordonne les éléments d'un regroupement de tableau avec reduce :

  • Tri vertical

    Ordonne les étapes de construction d'un pipeline fonctionnel de traitement de données :

  • Tri horizontal

    Ordonne les éléments d'une implémentation d'application partielle :

  • Clique dans l'ordre

    Ordonne les éléments d'un pipeline filter-map-reduce :

  • Tri vertical

    Ordonne les étapes de la refactorisation d'un code impératif vers un code fonctionnel :

  • Tri vertical

    Ordonne les couches d'un pipeline fonctionnel de traitement de données, de l'entrée à la sortie :

Articles utiles