Le refactoring fonctionnel
map, filter, reduce, fonctions pures et immutabilité : refactorer vers un style fonctionnel plus prévisible et testable.
La programmation fonctionnelle (functional programming) n'est pas, en JavaScript, un interrupteur que l'on bascule. C'est une direction vers laquelle on déplace son code, petit pas par petit pas, sous la protection des tests. Burchard insiste sur ce point : au niveau de base, la plupart des restrictions que l'on s'impose et des bénéfices que l'on récolte ressemblent moins à des « étapes vers la programmation fonctionnelle » qu'à du « code simplement meilleur ». Ne pas réassigner ses variables, garder des portées étroites, extraire des fonctions courtes à entrée et sortie claires : tout cela, nous l'avons déjà pratiqué tout au long du livre.
Ce chapitre montre comment franchir un cap. Nous allons fuir l'état partagé mutable, transformer les boucles impératives en map, filter et reduce, isoler les fonctions pures de leurs cousines impures, puis goûter à la composition, au currying et à l'application partielle. Le tout sans jamais perdre de vue le pragmatisme : JavaScript n'est pas un langage purement fonctionnel, et pousser le style jusqu'au point-free illisible serait une erreur.
Le pari fonctionnel : restrictions et bénéfices
Voici l'exemple que l'on dégaine toujours pour critiquer le style non fonctionnel :
let x = 1;
x = x + 1; Anodin ? Pas tant que ça. Si l'on lit ces deux lignes comme des faits mathématiques, x vaut-il 1, ou 1 + 1 ? Apparemment, cela dépend de l'endroit où l'on se trouve dans le programme. Ce ne sont donc plus des faits sur x, mais des instructions séquentielles. La réassignation introduit subrepticement la notion de temps dans le code : il existe un avant et un après. Or la programmation déclarative — dont la programmation fonctionnelle est une variante — cherche au contraire à décrire ce que le programme doit faire, plutôt que comment il doit le faire.
Adopter un style fonctionnel impose un certain nombre de restrictions. Dans un vrai langage fonctionnel, on en trouve souvent celles-ci :
- Les variables n'existent pas vraiment ; ce sont des valeurs (values), c'est-à-dire des constantes.
- Il n'y a pas d'état global partagé (shared state) sans difficulté.
- Une fonction doit toujours renvoyer quelque chose.
- Un
ifsanselseest invalide. - Il n'y a pas de
null.
Note
Pourquoi interdire un if sans else ? Parce qu'un if sans branche else change forcément la valeur retournée, lève une erreur, ou produit un autre effet. C'est un vecteur d'impureté qui se faufile dans nos fonctions.
En échange de ces contraintes viennent des bénéfices considérables. Quand les valeurs vivent dans une portée minuscule et ne changent jamais, on peut faire confiance à une fonction : appelée avec les mêmes arguments, elle renvoie toujours le même résultat. C'est l'idempotence, l'une des deux moitiés de ce qui fait une fonction pure (pure function) ; l'autre moitié, c'est l'absence d'effet de bord (side effect). De là découlent la transparence référentielle (referential transparency) — on peut remplacer un appel par sa valeur de retour — et l'absence de conditions de concurrence (race conditions), puisque deux cœurs exécutant la même fonction obtiennent le même résultat, quel que soit l'ordre.
Fuir mutation, réassignation et actions destructrices
La première hygiène fonctionnelle consiste à arrêter de modifier les choses. Quand on voit une réassignation, on cherche mieux. Le réflexe le plus simple : préférer const à let, qui exprime déjà l'intention de ne pas réassigner.
La réassignation s'invite souvent dans les conditionnelles. Burchard montre comment une fonction qui mute son paramètre peut se déplier jusqu'à une forme limpide :
// ❌ Avant : réassignation cachée dans la condition.
function func(x){
if((x = x + 7) >= 9){
return x;
} else {
return x;
}
}; En cessant de modifier x, on rend les deux branches explicites — et l'on découvre qu'elles renvoient la même chose :
// ✅ Après : les deux branches sont identiques, donc...
function func(x){
return x + 7;
}; Ce résultat n'était pas évident au départ. C'est tout l'intérêt : un refactoring mécanique, appliqué pas à pas sous tests, révèle parfois une simplification que l'on n'avait pas vue.
Les fonctions destructrices ont des cousines sages
Une autre source de mutation, ce sont les fonctions « destructrices » qui modifient leur receveur. splice est le coupable typique : avec un seul argument, il renvoie le reste du tableau mais saccage l'original. Et comme ce n'est pas une réassignation, const ne vous protège pas.
// ❌ Avant : splice mute le tableau d'origine.
const x = [1, 2, 3, 4];
x.splice(1); // renvoie [2, 3, 4]
// x vaut maintenant [1] ! // ✅ Après : slice se tient bien, x reste intact.
const z = [1, 2, 3, 4];
z.slice(1); // renvoie [2, 3, 4]
// z vaut toujours [1, 2, 3, 4] Attention
const empêche la réassignation, pas la mutation. Les tableaux exposent push, pop, fill ; les objets ont Object.assign (qui mute son premier argument), l'affectation par point (.prop =) ou par crochets ([clé] =). Aucune de ces opérations n'est bloquée par const. Si vous voulez une valeur « mise à jour », créez plutôt une nouvelle valeur avec un nouveau nom.
L'immutabilité (immutability) consiste donc à ne pas muter les données, mais à renvoyer de nouvelles valeurs. La copie via la syntaxe de décomposition (spread ...) est l'outil quotidien. Object.freeze peut geler un objet, et des bibliothèques comme Immutable.js ou mori imposent une immutabilité plus stricte que ce que const et .freeze offrent facilement.
// ❌ Avant : on mute un tableau partagé.
function ajouterTache(taches, nouvelle){
taches.push(nouvelle); // effet de bord !
return taches;
};
// ✅ Après : on renvoie un nouveau tableau.
function ajouterTache(taches, nouvelle){
return [...taches, nouvelle];
}; Burchard adopte pour ce chapitre une ligne dure : les actions destructrices ne sont pas acceptables. C'est le prix d'entrée pour obtenir des fonctions pures, idempotentes, testables, mémoïsables et parallélisables. Le billet peut sembler cher, mais il revient moins cher que de traquer des variables errantes mutées à travers des milliers de lignes de code.
Remplacer les boucles par map, filter et reduce
Le compteur de boucle est l'archétype de la variable qui se réassigne au fil du temps. À chaque tour, il pointe sur autre chose, et l'on bâtit un résultat en mutant une variable extérieure. Les fonctions d'ordre supérieur (higher-order functions) des tableaux — qui prennent une fonction en paramètre — éliminent ce bruit en créant atomiquement une nouvelle valeur. C'est plus déclaratif : on dit à la machine quoi faire, pas comment.
map transforme : à partir d'un tableau, il produit un nouveau tableau de la même longueur, chaque élément passé dans une fonction.
// ❌ Avant : boucle for qui mute un tableau de résultats.
function doubler(nombres){
const resultat = [];
for(let i = 0; i < nombres.length; i++){
resultat.push(nombres[i] * 2);
}
return resultat;
};
// ✅ Après : map décrit la transformation.
function doubler(nombres){
return nombres.map((nombre) => nombre * 2);
}; filter sélectionne : il renvoie un nouveau tableau ne contenant que les éléments qui passent un test, au lieu de créer un tableau vide et d'y pousser les éléments qui conviennent.
// ❌ Avant : on accumule à la main les éléments pairs.
function pairs(nombres){
const gardes = [];
for(let i = 0; i < nombres.length; i++){
if(nombres[i] % 2 === 0){
gardes.push(nombres[i]);
}
}
return gardes;
};
// ✅ Après : filter exprime le critère.
function pairs(nombres){
return nombres.filter((nombre) => nombre % 2 === 0);
}; reduce agrège : c'est l'outil quand on veut transformer un tableau en une autre sorte de valeur — un nombre, un objet, une chaîne. C'est le plus délicat des trois, mais aussi le plus puissant.
// ❌ Avant : un total accumulé dans une variable mutée.
function somme(nombres){
let total = 0;
for(let i = 0; i < nombres.length; i++){
total = total + nombres[i];
}
return total;
};
// ✅ Après : reduce condense la boucle.
function somme(nombres){
return nombres.reduce((acc, nombre) => acc + nombre, 0);
}; Astuce
Test rapide pour choisir entre map et reduce : regardez la ligne au-dessus du début de votre boucle. Si la variable que vous initialisez n'est pas du même type que ce que vous parcourez — un nombre, un objet vide — vous voulez probablement reduce. Si c'est un tableau de la même longueur en sortie, c'est map.
On peut tout faire avec une boucle for, c'est vrai : every, find, some, et les autres se réimplémentent toutes au-dessus de forEach ou de reduce. Mais pourquoi le ferait-on ? Le bon outil pour la bonne tâche : map donne un tableau transformé, les fonctions à prédicat (filter, find, some, every) répondent à un test, et reduce produit une valeur agrégée. Pour un simple effet de bord à exécuter, forEach suffit — mais sachez qu'un effet de bord reste un effet de bord.
Tenir l'impur à distance
Sauriez-vous distinguer les fonctions pures des impures dans ce trio ?
let x;
function add(addend1, addend2){
return addend1 + addend2;
};
function setGlobalFromAddition(addend1, addend2){
x = add(addend1, addend2); // mute un état global
};
function readAddition(addend1, addend2){
console.log(add(addend1, addend2)); // effet de bord d'I/O
}; add est pure : elle ne dépend que de ses entrées, renvoie une sortie sans aucun effet de bord, et se teste trivialement. setGlobalFromAddition mute la variable globale x : ses tests passent au début, mais imaginez que x soit une valeur en base de données, partagée par d'autres tests exécutés en parallèle — les échecs deviennent inévitables. readAddition appelle console.log, donc fait une entrée/sortie : impure elle aussi, et pénible à tester.
À retenir
Une fonction impure dépend d'autre chose que de ses entrées explicites : variables non locales, base de données, et même le this implicite s'il n'est pas immuable. C'est un virage majeur par rapport à l'orienté objet : en style fonctionnel, on préfère des entrées et sorties explicites à la lecture-écriture d'un this. Burchard va jusqu'à suggérer que le simple usage de this rend une fonction impure, car ce contexte n'est rien d'autre qu'un état mutable partagé.
La séparation est facile à appliquer si l'on sait déjà extraire des fonctions. À défaut de pouvoir effacer l'impureté, on peut au moins la regrouper sous un espace de noms explicite, pour signaler clairement où vivent les dangers :
// Les fonctions impures rassemblées et étiquetées.
const impure = {
setGlobalFromAddition(a, b){ x = add(a, b); },
readAddition(a, b){ console.log(add(a, b)); }
}; Trois leçons à retenir : il est facile de distinguer pur et impur ; les fonctions impures sont plus dures à tester ; et plus une fonction est simple — en volume, en entrées, en sorties — meilleures sont ses chances d'être pure.
Le cas du hasard
Math.random rend les fonctions impures : on ne peut pas compter sur la même sortie pour la même entrée. Ce que l'on obtient est en réalité un nombre pseudo-aléatoire, calculé à partir d'une graine (seed) liée au temps. Si l'on pouvait fixer cette graine — ce que Math.random ne permet pas nativement —, on pourrait régénérer la même séquence « aléatoire », et donc disposer de valeurs déterministes contre lesquelles écrire des tests. Même apparemment aléatoire, une fonction à graine fixée redevient déterministe.
Currying, application partielle et composition
Une fois l'hygiène en place, on peut explorer des outils plus avancés. Considérons l'addition la plus banale :
function add(numberOne, numberTwo){
return numberOne + numberTwo;
};
add(1, 2); // 3 Le currying consiste à transformer une fonction binaire en une fonction qui prend ses arguments un par un. L'application partielle (partial application) consiste à ne fournir qu'une partie des arguments pour obtenir une nouvelle fonction d'arité (arity) réduite — l'arité étant le nombre d'arguments attendus.
Pour que add(1) renvoie une fonction plutôt qu'un nombre, on curryfie d'abord add à la main : elle prend son premier argument, puis renvoie une fonction qui attend le second.
// add curryfié à la main : il renvoie une fonction.
function add(numberOne){
return (numberTwo) => numberOne + numberTwo; // attend le 2e argument
}; // ✅ add(1) fixe le premier argument : application partielle.
const incrementer = add(1);
incrementer(2); // 3 On a curryfié à la main ici. Pour curryfier n'importe quelle fonction d'arité quelconque, on s'appuie sur une bibliothèque comme Ramda :
const R = require('ramda');
function add(numberOne, numberTwo){
return numberOne + numberTwo;
};
const curriedAdd = R.curry(add);
curriedAdd(1); // [Function] (attend le second argument)
curriedAdd(1)(2); // 3
curriedAdd(1, 2); // 3 (l'arité d'origine marche encore) L'intérêt apparaît avec map. Avec un map natif, vos données sont coincées avant le point ([2, 4, 5].map(...)), ce qui empêche de réutiliser facilement la transformation. Le map de Ramda, déjà curryfié et fonction d'abord (la fonction avant les données), résout ça :
// ❌ Avant : la donnée est figée devant le point.
const square = (thing) => thing * thing;
[2, 4, 5].map(square);
// ✅ Après : on capture la transformation, on applique les données plus tard.
const mapSquares = R.map(square);
mapSquares([2, 4, 5]); // [4, 16, 25] L'ordre « fonction d'abord » est ce qui rend la composition (function composition) commode. R.compose enchaîne des fonctions de droite à gauche pour en fabriquer une nouvelle :
// ❌ Avant : on imbrique les appels à la main.
console.log(R.map(square, R.map(square, [2, 4, 5])));
// ✅ Après : on compose une fonction unique, lisible et réutilisable.
const fourthPower = R.compose(square, square);
const mapFourthPower = R.map(fourthPower);
mapFourthPower([2, 4, 5]); // [16, 256, 625] R.pipe fait la même chose dans l'ordre de lecture naturel (de gauche à droite). Les deux écritures ci-dessous sont équivalentes :
const printFact = R.compose(console.log, factorial); // droite -> gauche
const printFactPipe = R.pipe(factorial, console.log); // gauche -> droite Le pragmatisme : ne pas tomber dans le point-free illisible
Le style point-free (sans point, au sens topologique, rien à voir avec le . de JavaScript) consiste à ne pas manipuler ses entrées directement. Dès qu'on nomme une entrée et qu'on travaille avec, on crée un « point ».
// "Pointé" : on nomme et manipule data.
const mapSquares = (data) => _.map(data, square);
// "Point-free" : aucune entrée nommée.
const mapSquares = R.map(square); Viser le point-free aide à raccourcir beaucoup de définitions. Mais tout n'est pas si simple, et c'est là que le pragmatisme reprend ses droits. On pourrait croire que [3, 4, 2].forEach(console.log) revient à imprimer chaque nombre. Erreur : forEach fournit trois arguments à son callback, et console.log les imprime tous.
[3, 4, 2].forEach(console.log);
// 3 0 [ 3, 4, 2 ]
// 4 1 [ 3, 4, 2 ]
// 2 2 [ 3, 4, 2 ] // ✅ On fabrique une fonction unaire qui n'utilise que le premier argument.
const logFirst = (first) => console.log(first);
[3, 4, 2].forEach(logFirst); Piège courant
Il est tentant d'écrire une longue chaîne de compose et de map, mais déboguer ces chaînes est pénible. Ramda rend les fonctions très faciles à créer et à composer — y compris à enchaîner à l'excès. Cherchez l'équilibre : de petites fonctions composées, comme toute petite fonction, restent plus faciles à tester et à réutiliser. De même, le code purement fonctionnel privilégie des noms de variables très courts (a, b dans les signatures de type) ; n'extrayez pas tout au point de recréer un « callback hell » illisible.
JavaScript n'est pas Haskell. Il n'a ni étape de compilation, ni vérification de types statique, ni null interdit. Des bibliothèques comme Ramda ou Sanctuary apportent une partie de ces garanties — Sanctuary vérifie même les types à l'exécution, et propose Just/Nothing plutôt que null —, mais l'on peut adopter ce style de façon incrémentale, quelques require à la fois. La programmation fonctionnelle n'a pas à être du tout ou rien.
Du paradigme objet vers le fonctionnel
Passer de l'orienté objet au fonctionnel dépasse le simple refactoring : c'est une restructuration, parfois trop massive pour mériter encore ce nom. Burchard en donne les symptômes sur son classifieur bayésien : on n'exporte plus une classe mais des fonctions individuelles, il n'y a plus aucun this, et faute d'état interne, on doit « transporter » l'état nécessaire en valeurs de retour. Conséquence directe : train et classify deviennent idempotentes et pures — mêmes entrées, mêmes sorties, aucun effet de bord. Le module devient un simple espace de noms qui ne détient aucune donnée, juste des fonctions dont la sortie alimente d'autres fonctions.
Les étapes utiles, dans n'importe quel ordre, ressemblent à ceci :
- Disposer de bons tests de haut niveau ; si quelque chose casse, annuler ses changements.
- Faire renvoyer un objet utile (souvent
return thisau début) à toute fonction qui ne renvoyait rien. - Remplacer les références à
thispar des références explicites à l'objet. - Aplatir les objets imbriqués, sortir les propriétés des objets.
- Remplacer les variables d'état par des requêtes ; au besoin, passer l'ancien état en paramètre jusqu'à ce qu'il devienne une simple entrée de fonction.
Note
Attendez-vous à dégrader le code avant de l'améliorer. On déstructure avant de restructurer : on ajoute temporairement de la duplication, on fait circuler plus de paramètres, on introduit des fonctions intermédiaires et des noms maladroits. C'est normal. Comme pour tout refactoring, on avance par petits pas confiants, sous contrôle de version et avec une suite de tests au vert.
Et l'on n'est pas obligé de tout composer. Burchard explique pourquoi il a gardé addText, train et classify séparées : addText sert à bâtir progressivement un jeu de données, train peut être lent et mérite d'être isolé pour le déléguer à un autre processus, et garder une API petite offre une seule façon raisonnable d'utiliser la bibliothèque. La composition est un outil, pas un objectif en soi.
À retenir
- L'immutabilité d'abord : préférez
const, ne mutez rien, renvoyez de nouvelles valeurs (spread...,sliceplutôt quesplice). Rappelez-vous queconstinterdit la réassignation, pas la mutation. - Une fonction pure dépend uniquement de ses entrées et n'a aucun effet de bord : testable, prévisible, mémoïsable, parallélisable. Isolez ou regroupez les fonctions impures, et préférez entrées/sorties explicites à un
thismutable. - map, filter, reduce remplacent les boucles impératives et leurs variables d'état :
maptransforme,filtersélectionne,reduceagrège. Choisissezreducequand le résultat n'est pas du même type que l'entrée. - Currying, application partielle et composition (via Ramda/Sanctuary, « fonction d'abord ») fabriquent des interfaces plus courtes, flexibles et réutilisables.
- Pragmatisme : visez le point-free sans tomber dans l'illisible ; gardez de petites fonctions composées ; JavaScript n'est pas purement fonctionnel et le style s'adopte de façon incrémentale.
- Du paradigme objet au fonctionnel, c'est une restructuration, pas un simple refactoring : petits pas, contrôle de version, tests au vert — et acceptez de dégrader le code avant de l'améliorer.