Cours JavaScript et TypeScript · Module 12: Programmation fonctionnelle

Composition de fonctions : compose et pipe

7 min de lecture
Dans cette leçon5

Le nom de l'espèce arrive du capteur sous la forme ' rex ' et, avant d'entrer dans le registre, il doit passer par trois traitements. L'écriture addPrefix(capitalize(normalize(name))) se lit de l'intérieur vers l'extérieur, et avec cinq étapes, les parenthèses commencent à ressembler à un brin d'ADN emmêlé. Nous voulons écrire les étapes dans l'ordre où elles s'exécutent réellement.

Dans le laboratoire génétique de Jurassic Park, l'ADN d'un dinosaure passe par une série d'étapes de traitement : extraction, purification, séquençage, validation. Chaque étape est une opération distincte, et leur enchaînement forme le processus complet. C'est exactement ainsi que fonctionne la composition de fonctions : on combine des fonctions simples pour obtenir des opérations plus complexes.

Qu'est-ce que la composition de fonctions ?

La composition de fonctions consiste à combiner deux fonctions ou plus, de sorte que le résultat de l'une devienne l'entrée de la suivante. En mathématiques : f(g(x)) - on applique d'abord g, puis f.

Voici trois postes de traitement du nom : trim supprime les espaces aux extrémités, charAt(0).toUpperCase() met la première lettre en majuscule, et slice(1) y recolle le reste du texte :

1// Opérations individuelles sur les données d'un dinosaure
2const normalize = (name) => name.trim().toLowerCase();
3const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
4const addPrefix = (name) => 'DINO-' + name;

Chaque fonction est pure et fait une seule chose. Combinons-les maintenant de deux façons : d'abord en imbriquant les appels, puis en les enfermant dans une fonction nommée :

1// Sans composition - appels imbriqués (difficiles à lire)
2const result = addPrefix(capitalize(normalize('  rex  ')));
3// 'DINO-Rex'
4
5// Avec composition - un pipeline lisible
6const formatDinoName = (name) => addPrefix(capitalize(normalize(name)));
7formatDinoName('  rex  '); // 'DINO-Rex'

Les deux écritures donnent 'DINO-Rex'. La seconde version donne un nom au processus et peut être réutilisée, mais l'ordre des étapes se lit toujours de droite à gauche. Les fonctions compose et pipe automatisent ce genre d'assemblage pour un nombre quelconque d'étapes.

La fonction compose

compose combine les fonctions de droite à gauche : la dernière fonction de la liste s'exécute en premier. C'est la transposition de la notation mathématique f(g(x)).

Le paramètre du reste ...fns rassemble toutes les fonctions passées dans un tableau. reduceRight fonctionne comme reduce, mais parcourt le tableau depuis la fin, et la valeur initiale de l'accumulateur, ce sont les données d'entrée :

1function compose(...fns) {
2  return (value) => fns.reduceRight(
3    (acc, fn) => fn(acc),
4    value
5  );
6}

La fonction compose elle-même ne traite encore rien : elle renvoie seulement une nouvelle fonction qui attend les données. Testons-la sur trois étapes de traitement d'un échantillon :

1// Fonctions de traitement d'un échantillon d'ADN
2const extractDNA = (sample) => ({ ...sample, dna: sample.tissue + '-DNA' });
3const sequenceDNA = (sample) => ({ ...sample, sequence: sample.dna + '-SEQ' });
4const validateDNA = (sample) => ({ ...sample, valid: sample.sequence.length > 5 });
5
6// Compose : de droite à gauche (extractDNA -> sequenceDNA -> validateDNA)
7const processSample = compose(validateDNA, sequenceDNA, extractDNA);
8
9const result = processSample({ tissue: 'TREX-001' });
10// { tissue: 'TREX-001', dna: 'TREX-001-DNA', sequence: 'TREX-001-DNA-SEQ', valid: true }

extractDNA s'exécute en premier, car c'est elle qui se trouve le plus à droite. Chaque étape renvoie un nouvel objet grâce au spread, donc l'échantillon d'entrée { tissue: 'TREX-001' } reste intact, et le résultat rassemble les champs ajoutés en chemin.

La fonction pipe

pipe est l'inverse de compose : elle combine les fonctions de gauche à droite. Elle se lit comme un flux naturel de données, ce que beaucoup de développeurs trouvent plus intuitif.

L'implémentation diffère d'un seul mot, reduce au lieu de reduceRight, et la première fonction de la liste s'exécute en premier :

1function pipe(...fns) {
2  return (value) => fns.reduce(
3    (acc, fn) => fn(acc),
4    value
5  );
6}
7
8// Pipe : de gauche à droite (sens de lecture naturel)
9const processSample = pipe(extractDNA, sequenceDNA, validateDNA);
10// Résultat identique, mais un ordre plus lisible

Les trois mêmes étapes donnent le même résultat qu'avec compose, mais l'ordre des arguments correspond désormais à l'ordre d'exécution. Les étapes peuvent aussi être des fonctions écrites sur place, comme dans ce rapport sur les dinosaures actifs :

1// Exemple pratique - traitement des données du parc
2const processReport = pipe(
3  (data) => data.filter((d) => d.status === 'active'),
4  (data) => data.map((d) => ({ name: d.name, zone: d.zone })),
5  (data) => data.sort((a, b) => a.name.localeCompare(b.name)),
6);
7
8const parkData = [
9  { name: 'Rex', zone: 'A', status: 'active' },
10  { name: 'Stego', zone: 'B', status: 'inactive' },
11  { name: 'Blue', zone: 'A', status: 'active' },
12];
13processReport(parkData);
14// [{ name: 'Blue', zone: 'A' }, { name: 'Rex', zone: 'A' }]

Stego tombe au filtre, map garde le nom et la zone, et sort avec localeCompare range le résultat par ordre alphabétique. Attention au piège : sort modifie le tableau sur lequel tu l'appelles. Ici, c'est sans danger, car on trie un tableau tout neuf issu de map, et parkData garde son ordre d'origine. Si le tri était la première étape, il faudrait utiliser toSorted (ES2023), qui renvoie une copie triée.

Compose vs Pipe - Lequel utiliser et quand ?

Les deux fonctions font la même chose, mais dans l'ordre inverse. Le choix dépend des préférences, comme le montre bien ce schéma à trois étapes :

1// compose : de droite à gauche (comme en mathématiques)
2const process1 = compose(validate, transform, parse);
3// À lire : validate(transform(parse(x)))
4
5// pipe : de gauche à droite (comme un flux naturel)
6const process2 = pipe(parse, transform, validate);
7// À lire : x -> parse -> transform -> validate

En pratique, pipe est plus souvent utilisé, car il reflète le sens naturel du traitement des données - de l'entrée vers la sortie, de haut en bas.

Comment construire un pipeline

Voici un ordre de travail qui a fait ses preuves : définis d'abord une fonction pure pour chaque étape, teste ensuite chacune séparément, combine-les avec pipe ou compose, et passe enfin les données d'entrée au pipeline. Respecte une règle : chaque étape accepte un seul argument et renvoie ce qu'attend la suivante. Si l'une d'elles oublie le return, la suivante recevra undefined.

Quand tu veux jeter un œil aux données entre deux étapes, insère une fonction auxiliaire tap. Elle exécute un effet de bord, mais transmet exactement la même valeur :

1// Aperçu des données entre les étapes, sans changer le résultat
2const tap = (fn) => (value) => {
3  fn(value);
4  return value;
5};
6
7const debugReport = pipe(
8  (data) => data.filter((d) => d.status === 'active'),
9  tap((data) => console.log('Après le filtre :', data.length)),
10  (data) => data.map((d) => d.name),
11);
12
13debugReport(parkData); // ['Rex', 'Blue'], dans la console : Après le filtre : 2

La console affiche le nombre de dinosaures actifs, et le résultat final reste une simple liste de leurs noms - tap n'y a rien changé. Je te recommande de garder tap sous la main pour le débogage et de le retirer avant le déploiement. Dans la leçon suivante, tu verras comment le currying transforme des fonctions à plusieurs arguments en fonctions à un seul argument, qui s'emboîtent parfaitement dans pipe.

Retiens : pipe est le tapis roulant du laboratoire - l'échantillon entre par la gauche, passe par les postes successifs et ressort à droite sous forme de résultat final.

Code de cette leçon : index.js
1// Composition de fonctions : compose et pipe
2console.log("=== Jurassic Park - Recombinaison de l'ADN ===\n");
3
4// Implementation de compose (droite -> gauche)
5function compose(...fns) {
6  return (value) => fns.reduceRight((acc, fn) => fn(acc), value);
7}
8
9// Implementation de pipe (gauche -> droite)
10function pipe(...fns) {
11  return (value) => fns.reduce((acc, fn) => fn(acc), value);
12}
13
14// --- EXEMPLE 1 : Formatage des noms ---
15console.log("--- Compose: Formatage des noms ---");
16const normalize = (str) => str.trim().toLowerCase();
17const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
18const addPrefix = (str) => "DINO-" + str;
19
20const formatName = compose(addPrefix, capitalize, normalize);
21console.log(formatName("  rex  "));       // DINO-Rex
22console.log(formatName(" VELOCIRAPTOR ")); // DINO-Velociraptor
23
24// --- EXEMPLE 2 : Pipeline de traitement de l'ADN ---
25console.log("\n--- Pipe: Traitement d'un echantillon d'ADN ---");
26
27const extractDNA = (sample) => ({ ...sample, dna: sample.tissue + "-DNA" });
28const sequenceDNA = (sample) => ({ ...sample, sequence: sample.dna + "-SEQ" });
29const assessQuality = (sample) => ({
30  ...sample,
31  quality: sample.sequence.length > 15 ? "HIGH" : "LOW",
32});
33const createReport = (sample) => ({
34  ...sample,
35  report: sample.quality + " quality sample from " + sample.tissue,
36});
37
38const processSample = pipe(extractDNA, sequenceDNA, assessQuality, createReport);
39
40const result = processSample({ tissue: "TREX-001" });
41console.log("Result:", JSON.stringify(result, null, 2));
42
43// --- EXEMPLE 3 : Traitement des donnees du parc ---
44console.log("\n--- Pipe: Rapport de securite ---");
45
46const parkData = [
47  { name: "Rex", zone: "A", status: "active", danger: 9 },
48  { name: "Brachio", zone: "B", status: "active", danger: 2 },
49  { name: "Raptor", zone: "A", status: "escaped", danger: 8 },
50  { name: "Stego", zone: "B", status: "active", danger: 3 },
51  { name: "Blue", zone: "A", status: "active", danger: 7 },
52];
53
54const generateReport = pipe(
55  (data) => data.filter((d) => d.status === "active"),
56  (data) => data.filter((d) => d.danger >= 5),
57  (data) => data.map((d) => ({ name: d.name, zone: d.zone, level: d.danger > 7 ? "CRITICAL" : "HIGH" })),
58  (data) => [...data].sort((a, b) => a.name.localeCompare(b.name)),
59);
60
61const report = generateReport(parkData);
62console.log("Menaces actives:");
63report.forEach((r) => console.log(`  [${r.level}] ${r.name} - Zone ${r.zone}`));
64
65// --- EXEMPLE 4 : Compose vs Pipe ---
66console.log("\n--- Comparaison compose vs pipe ---");
67const double = (x) => x * 2;
68const addTen = (x) => x + 10;
69const square = (x) => x * x;
70
71const withCompose = compose(square, addTen, double);
72const withPipe = pipe(double, addTen, square);
73
74console.log("compose(square, addTen, double)(3):", withCompose(3));
75console.log("pipe(double, addTen, square)(3):", withPipe(3));
76console.log("Les deux: 3 -> double(6) -> addTen(16) -> square(256)");

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. Dans quel ordre pipe exécute-t-il les fonctions ?

  2. 2. Quelle est la différence entre compose et pipe ?

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

Tâches pratiques dans le jeu

  • Éditeur de code

    Écris des fonctions de normalisation et de classification, puis combine-les avec pipe.

  • Tri horizontal

    Ordonne les éléments de l'implémentation de la fonction pipe :

  • Éditeur de code

    Regroupe les données par zone, calcule des statistiques par type de capteur et compte les alertes.

Articles utiles