Cours JavaScript et TypeScript · Module 12: Programmation fonctionnelle

Projet : Pipeline de données fonctionnel pour Jurassic Park

7 min de lecture
Dans cette leçon6

L'équipe de nuit reçoit des centaines de relevés des capteurs : mouvement, tension des clôtures, température. Une partie d'entre eux est endommagée, parce qu'il manque l'identifiant ou que la valeur est négative, et le matin, le responsable veut un seul rapport : quelles zones sont sûres, et où il faut envoyer une équipe. Un énorme script plein de boucles et de drapeaux devient vite impossible à maintenir.

Il est temps de réunir toutes les techniques de programmation fonctionnelle dans un seul projet. Nous allons construire un système fonctionnel de traitement des données des dinosaures - des relevés bruts des capteurs jusqu'aux rapports de sécurité finaux. Chaque étape est une fonction pure, et l'ensemble du processus est un pipeline construit avec pipe et compose.

Architecture du projet

Notre système se compose de plusieurs couches :

  1. Parsing - lecture des données brutes des capteurs
  2. Validation - vérification de l'exactitude des données (Maybe/Either)
  3. Transformation - traitement des données (map, filter)
  4. Agrégation - synthèses et statistiques (reduce)
  5. Rapport - génération du rapport final

Nous travaillons sur six relevés, parmi lesquels nous avons volontairement caché deux pièges :

1// Structure des données d'entrée
2const sensorReadings = [
3  { sensorId: 'S01', zone: 'A', type: 'motion', value: 85, timestamp: 1700000000 },
4  { sensorId: 'S02', zone: 'A', type: 'fence', value: 10000, timestamp: 1700000001 },
5  { sensorId: 'S03', zone: 'B', type: 'motion', value: 12, timestamp: 1700000002 },
6  { sensorId: 'S04', zone: 'B', type: 'temperature', value: 38, timestamp: 1700000003 },
7  { sensorId: null, zone: 'C', type: 'motion', value: -5, timestamp: 1700000004 },
8  { sensorId: 'S06', zone: 'A', type: 'fence', value: 0, timestamp: 1700000005 },
9];

Le cinquième relevé n'a pas de sensorId et a une valeur négative - ce sont des déchets que la validation doit rejeter. Le sixième signale une clôture à 0 volt : des données correctes, mais alarmantes. timestamp est un temps Unix, c'est-à-dire le nombre de secondes écoulées depuis le 1er janvier 1970.

La couche de validation avec Maybe

Le validateur renvoie le relevé ou null, et le Maybe de la leçon précédente transforme null en un conteneur vide et sans danger. Ici, une version allégée avec map et getOrElse suffit :

1class Maybe {
2  constructor(value) { this.value = value; }
3  static of(v) { return new Maybe(v); }
4  isNothing() { return this.value === null || this.value === undefined; }
5  map(fn) { return this.isNothing() ? this : Maybe.of(fn(this.value)); }
6  getOrElse(def) { return this.isNothing() ? def : this.value; }
7}
8
9const validateReading = (reading) => {
10  if (!reading.sensorId) return null;
11  if (reading.value < 0) return null;
12  return reading;
13};
14
15const safeValidate = (reading) =>
16  Maybe.of(reading)
17    .map(validateReading)
18    .getOrElse(null);

safeValidate enveloppe le relevé, le fait passer par le validateur et le déballe aussitôt : un relevé correct revient inchangé, et un relevé endommagé devient null. Le relevé S06 passe, car 0 n'est pas une valeur négative. C'est une simplification voulue, car les étapes suivantes travaillent sur des tableaux ordinaires, et nous éliminerons les null avec un seul filter.

Construire le pipeline

D'abord trois petites fonctions pures pour un seul relevé. Chacune accepte un relevé et renvoie un nouvel objet, sans toucher à l'original :

1// Fonctions pures - chacune fait une seule chose
2const isValidReading = (r) => r !== null;
3const enrichWithDate = (r) => ({
4  ...r,
5  date: new Date(r.timestamp * 1000).toISOString(),
6});
7const classifyAlert = (r) => ({
8  ...r,
9  alert: r.type === 'fence' && r.value < 5000 ? 'CRITICAL' :
10         r.type === 'motion' && r.value > 80 ? 'WARNING' : 'OK',
11});

enrichWithDate multiplie l'horodatage par 1000, car Date compte en millisecondes, et enregistre la date au format ISO, par exemple '2023-11-14T22:13:20.000Z'. classifyAlert utilise un opérateur conditionnel imbriqué : une clôture en dessous de 5000 V donne CRITICAL, un mouvement au-dessus de 80 donne WARNING, et tout le reste OK.

Les deux fonctions suivantes travaillent sur toute la collection. Le regroupement reprend le motif à clé calculée vu dans la leçon sur reduce, et les statistiques sont calculées pour chaque paire [zone, relevés] fournie par Object.entries :

1// Agrégation par zone
2const groupByZone = (readings) =>
3  readings.reduce((groups, r) => ({
4    ...groups,
5    [r.zone]: [...(groups[r.zone] || []), r],
6  }), {});
7
8// Statistiques par zone
9const calculateZoneStats = (grouped) =>
10  Object.entries(grouped).map(([zone, readings]) => ({
11    zone,
12    totalReadings: readings.length,
13    alerts: readings.filter((r) => r.alert !== 'OK').length,
14    avgValue: readings.reduce((s, r) => s + r.value, 0) / readings.length,
15    status: readings.some((r) => r.alert === 'CRITICAL') ? 'DANGER' : 'SAFE',
16  }));

some vérifie si au moins un relevé de la zone est critique, et dans ce cas, toute la zone reçoit le statut DANGER. Remarque de conception : avgValue fait la moyenne du mouvement, des volts et des degrés tous ensemble, ce qui n'aurait aucun sens dans un vrai rapport. C'est un bon endroit pour ta première amélioration.

Le pipeline complet avec pipe

Maintenant, on assemble tout en une seule chaîne de production. L'ordre des étapes dans pipe est exactement leur ordre d'exécution :

1function pipe(...fns) {
2  return (value) => fns.reduce((acc, fn) => fn(acc), value);
3}
4
5const generateSecurityReport = pipe(
6  // 1. Validation de chaque relevé
7  (readings) => readings.map(safeValidate),
8  // 2. Élimination des relevés invalides
9  (readings) => readings.filter(isValidReading),
10  // 3. Enrichissement avec la date
11  (readings) => readings.map(enrichWithDate),
12  // 4. Classification des alertes
13  (readings) => readings.map(classifyAlert),
14  // 5. Regroupement par zone
15  groupByZone,
16  // 6. Statistiques
17  calculateZoneStats,
18  // 7. Tri - les plus dangereuses d'abord
19  (stats) => [...stats].sort((a, b) => b.alerts - a.alerts),
20);
21
22const report = generateSecurityReport(sensorReadings);

Le rapport contient deux entrées. Zone A : trois relevés, deux alertes (le mouvement à 85 et la clôture à 0 V), statut DANGER. Zone B : deux relevés, zéro alerte, statut SAFE. La zone C n'y figure pas du tout, car son unique relevé a été éliminé à la validation - dans un vrai parc, un secteur silencieux est en soi une raison de s'inquiéter. La dernière étape copie le tableau avant sort, donc même le tri ne mute rien, et sensorReadings est identique avant et après tout le processus.

Extension - Fonctions utilitaires curryfiées

Pour finir, des outils de style curryfié. Tu fournis d'abord la configuration, et le tableau à la fin, donc une fois configurée, chaque fonction s'emboîte directement dans pipe :

1const filterBy = (key) => (value) => (arr) =>
2  arr.filter((item) => item[key] === value);
3
4const mapField = (key) => (fn) => (arr) =>
5  arr.map((item) => ({ ...item, [key]: fn(item[key]) }));
6
7const filterByZone = filterBy('zone');
8const filterZoneA = filterByZone('A');
9
10const normalizeValues = mapField('value')((v) => Math.round(v * 100) / 100);

filterZoneA est un filtre tout prêt pour la zone A, et normalizeValues arrondit le champ value à deux décimales dans une copie de chaque relevé. Tu peux insérer ces deux fonctions comme étapes supplémentaires de generateSecurityReport.

Ta mission pour le mentor

Dans l'éditeur ci-dessous, tu trouveras un projet complet et fonctionnel à explorer et à modifier. Avant d'envoyer le lien à ton mentor, enrichis-le : remplace Maybe par Either, pour que le rapport liste les relevés rejetés avec leur motif, calcule les moyennes séparément pour chaque type de capteur, et signale les zones dont aucun relevé correct n'est arrivé. Vérifie aussi que l'entrée reste intacte : gèle sensorReadings et chaque relevé avec Object.freeze, et en mode strict, toute tentative de mutation lèvera une TypeError. Tu retrouveras ces mêmes motifs dans le Monde 2 : les reducers de Redux sont des fonctions pures, et l'état dans React se met à jour avec des copies immuables.

Retiens : un bon pipeline est une chaîne de laboratoire faite de petits postes purs - chacun fait une seule chose, aucun ne touche à l'échantillon original, et les mêmes données donnent toujours le même rapport.

Code de cette leçon : index.js
1// Projet : Pipeline de donnees fonctionnel de Jurassic Park
2console.log("=== Jurassic Park - Systeme de Rapports Complet ===\n");
3
4// --- Monade Maybe ---
5class Maybe {
6  constructor(value) { this.value = value; }
7  static of(v) { return new Maybe(v); }
8  isNothing() { return this.value === null || this.value === undefined; }
9  map(fn) { return this.isNothing() ? this : Maybe.of(fn(this.value)); }
10  getOrElse(def) { return this.isNothing() ? def : this.value; }
11}
12
13// --- Fonctions utilitaires ---
14function pipe(...fns) {
15  return (value) => fns.reduce((acc, fn) => fn(acc), value);
16}
17
18function curry(fn) {
19  return function curried(...args) {
20    if (args.length >= fn.length) return fn(...args);
21    return (...more) => curried(...args, ...more);
22  };
23}
24
25const filterBy = curry((field, value, arr) =>
26  arr.filter((item) => item[field] === value)
27);
28
29// --- Donnees d'entree (releves des capteurs) ---
30const sensorReadings = [
31  { sensorId: "S01", zone: "A", type: "motion", value: 85, ts: 1700000000 },
32  { sensorId: "S02", zone: "A", type: "fence", value: 10000, ts: 1700000001 },
33  { sensorId: "S03", zone: "B", type: "motion", value: 12, ts: 1700000002 },
34  { sensorId: "S04", zone: "B", type: "temperature", value: 38, ts: 1700000003 },
35  { sensorId: null, zone: "C", type: "motion", value: -5, ts: 1700000004 },
36  { sensorId: "S06", zone: "A", type: "fence", value: 0, ts: 1700000005 },
37  { sensorId: "S07", zone: "C", type: "motion", value: 92, ts: 1700000006 },
38  { sensorId: "S08", zone: "B", type: "fence", value: 8500, ts: 1700000007 },
39];
40
41// --- Couche de validation ---
42const validateReading = (reading) =>
43  Maybe.of(reading)
44    .map((r) => r.sensorId ? r : null)
45    .map((r) => r && r.value >= 0 ? r : null)
46    .getOrElse(null);
47
48// --- Fonctions pures de transformation ---
49const isValid = (r) => r !== null;
50
51const enrichWithDate = (r) => ({
52  ...r,
53  date: new Date(r.ts * 1000).toISOString().split("T")[0],
54});
55
56const classifyAlert = (r) => ({
57  ...r,
58  alert: r.type === "fence" && r.value < 5000 ? "CRITICAL"
59       : r.type === "motion" && r.value > 80 ? "WARNING"
60       : "OK",
61});
62
63// --- Agregation ---
64const groupByZone = (readings) =>
65  readings.reduce((groups, r) => ({
66    ...groups,
67    [r.zone]: [...(groups[r.zone] || []), r],
68  }), {});
69
70const calculateZoneStats = (grouped) =>
71  Object.entries(grouped).map(([zone, readings]) => ({
72    zone,
73    totalReadings: readings.length,
74    criticalAlerts: readings.filter((r) => r.alert === "CRITICAL").length,
75    warnings: readings.filter((r) => r.alert === "WARNING").length,
76    avgValue: Math.round(readings.reduce((s, r) => s + r.value, 0) / readings.length),
77    status: readings.some((r) => r.alert === "CRITICAL") ? "DANGER" : "SAFE",
78  }));
79
80// --- Pipeline complet ---
81const generateSecurityReport = pipe(
82  (readings) => readings.map(validateReading),
83  (readings) => readings.filter(isValid),
84  (readings) => readings.map(enrichWithDate),
85  (readings) => readings.map(classifyAlert),
86  groupByZone,
87  calculateZoneStats,
88  (stats) => [...stats].sort((a, b) => b.criticalAlerts - a.criticalAlerts),
89);
90
91// --- Execution ---
92console.log("Donnees d'entree:", sensorReadings.length, "releves\n");
93
94const report = generateSecurityReport(sensorReadings);
95
96console.log("=== RAPPORT DE SECURITE ===\n");
97report.forEach((zone) => {
98  const icon = zone.status === "DANGER" ? "!!!" : "OK ";
99  console.log(`[${icon}] Zone ${zone.zone}:`);
100  console.log(`    Releves: ${zone.totalReadings}`);
101  console.log(`    Critiques: ${zone.criticalAlerts}`);
102  console.log(`    Avertissements: ${zone.warnings}`);
103  console.log(`    Valeur moyenne: ${zone.avgValue}`);
104  console.log(`    Status: ${zone.status}\n`);
105});
106
107// --- Filtrage du rapport (curryfie) ---
108console.log("=== Rapports filtres ===\n");
109
110const filterByStatus = filterBy("status");
111const dangerZones = filterByStatus("DANGER")(report);
112console.log("Zones DANGER:", dangerZones.map((z) => z.zone));
113
114const safeZones = filterByStatus("SAFE")(report);
115console.log("Zones SAFE:", safeZones.map((z) => z.zone));
116
117// --- Synthese ---
118const summary = report.reduce((acc, z) => ({
119  totalReadings: acc.totalReadings + z.totalReadings,
120  totalCritical: acc.totalCritical + z.criticalAlerts,
121  totalWarnings: acc.totalWarnings + z.warnings,
122}), { totalReadings: 0, totalCritical: 0, totalWarnings: 0 });
123
124console.log("\n=== SYNTHESE ===");
125console.log("Total des releves:", summary.totalReadings);
126console.log("Total critiques:", summary.totalCritical);
127console.log("Total avertissements:", summary.totalWarnings);

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

Articles utiles