On présente souvent les closures comme une curiosité du langage, un point de théorie qu’on coche à la lecture d’un livre sur JavaScript avant de passer à autre chose. C’est une erreur de perspective. Les closures ne sont pas une note de bas de page : elles sont le mécanisme silencieux sur lequel repose une grande partie du JavaScript que vous écrivez tous les jours, souvent sans même le savoir.
Dans le premier volet de cette série, nous avons vu ce qu’est réellement une closure : une fonction qui capture et conserve l’accès à son environnement lexical, même après que la fonction parente a fini de s’exécuter. Restait une question : à quoi cela sert-il concrètement ? Voici une liste, non exhaustive, de six concepts fondamentaux qui ne tiennent debout que grâce aux closures. Vous en utilisez déjà la plupart.
Currying
Le currying consiste à transformer une fonction qui prend plusieurs arguments en une série de fonctions ne prenant chacune qu’un seul argument. Au lieu d’appeler f(a, b, c), on appelle f(a)(b)(c). Chaque fonction intermédiaire capture, via une closure, les arguments des étapes précédentes pour les rendre disponibles à la suivante.
function multiply(a) {
return function (b) {
return function (c) {
return a * b * c;
};
};
}
multiply(2)(3)(4); // 24
Ici, la fonction la plus interne accède toujours à a et à b alors que les fonctions qui les ont introduits ont déjà retourné depuis longtemps. C’est exactement le rôle de la closure : maintenir vivant ce contexte lexical d’un appel à l’autre. Sans elle, a et b auraient disparu de la pile bien avant que la multiplication finale puisse avoir lieu.
Application partielle
L’application partielle permet de créer une version spécialisée d’une fonction en pré-fournissant une partie de ses arguments. On obtient une nouvelle fonction, plus précise, qui n’attend plus que les arguments restants. Il existe deux manières classiques de la mettre en pratique.
Par le currying
Une fonction curryfiée fait naturellement de l’application partielle : appeler la fonction parente retourne une fonction pour laquelle le premier argument est déjà fourni et conservé dans une closure.
function add(a) {
return function (b) {
return a + b;
};
}
const add10 = add(10); // `a` (= 10) est figé dans la closure
add10(5); // 15
add10(20); // 30
add10 est une variante spécialisée d’add : la valeur 10 y est définitivement capturée. Chaque appel ultérieur réutilise ce contexte sans avoir à le repréciser.
Par le binding d’arguments
L’autre voie consiste à pré-remplir les arguments d’une fonction existante. En JavaScript, Function.prototype.bind crée précisément une closure qui mémorise le contexte (this) et les arguments fournis à l’avance.
function greet(greeting, name) {
return `${greeting}, ${name} !`;
}
const sayHello = greet.bind(null, "Bonjour");
sayHello("Antoine"); // "Bonjour, Antoine !"
Dans les deux cas, le résultat est le même : on dérive une version pré-configurée d’une fonction générique, et c’est une closure qui retient les valeurs déjà appliquées.
Higher-Order Functions
Une higher-order function (fonction d’ordre supérieur) est une fonction qui prend une autre fonction en argument, en retourne une, ou les deux. Ce sont elles qui rendent le code JavaScript composable. Les closures leur sont indispensables : elles permettent à la fonction produite de capturer le contexte lexical défini au moment de sa création.
function makeMultiplier(factor) {
return (value) => value * factor;
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
double(5); // 10
triple(5); // 15
double et triple partagent le même code mais embarquent chacune leur propre factor, capturé dans une closure distincte. C’est ce mécanisme qui se cache derrière map, filter, reduce et la plupart des fonctions qui manipulent d’autres fonctions.
Callbacks et event listeners
Le code asynchrone repose massivement sur les callbacks et les event listeners. Or, entre le moment où une action est définie et le moment où elle se déclenche réellement, il s’écoule un temps, parfois quelques millisecondes, parfois beaucoup plus. Pendant cet intervalle, le contexte de la fonction d’origine doit être préservé. C’est, là encore, le travail d’une closure.
/**
* 4. CALLBACKS et EVENT LISTENERS
*
* Pour gérer du code asynchrone, des callbacks et events listeners peuvent être
* utilisés. Le plus souvent, ces mécanismes font recourt à des Closures pour
* préserver le contexte entre le moment où l'action est définie et le moment où
* elle est déclenchée.
*/
function doSomethingAsync(input: number, callback: (result: string) => void) {
const result = `processed ${input} items`;
setImmediate(() => {
callback(result);
});
}
doSomethingAsync(1000, (result) => {
console.log(result); // processed 1000 items
});
Lorsque setImmediate exécute enfin sa fonction, l’appel à doSomethingAsync est terminé depuis longtemps. Pourtant, result est toujours accessible : la fonction passée à setImmediate a capturé la variable dans une closure. Sans ce mécanisme, toute la programmation asynchrone basée sur des callbacks serait impossible.
Memoization
La memoization est une technique d’optimisation qui consiste à mémoriser le résultat d’une fonction pour un jeu d’arguments donné, afin d’éviter de la ré-exécuter inutilement si on la rappelle avec les mêmes entrées. Le cache qui stocke ces résultats doit survivre entre les appels sans pour autant polluer la portée globale : une closure est l’endroit idéal pour le loger.
function memoize(fn) {
const cache = new Map(); // conservé dans la closure, privé et persistant
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
const slowSquare = (n) => {
// imaginez ici un calcul coûteux
return n * n;
};
const fastSquare = memoize(slowSquare);
fastSquare(4); // calculé puis mis en cache
fastSquare(4); // servi depuis le cache, sans recalcul
Le cache n’existe nulle part ailleurs que dans la closure créée par memoize. Il est invisible de l’extérieur, propre à chaque fonction mémoïsée, et persiste aussi longtemps que la fonction retournée reste référencée.
Encapsulation
Enfin, les closures permettent d’émuler des états privés, chose que JavaScript ne proposait pas nativement avant l’arrivée des champs privés de classe (#field). En capturant des variables dans un champ lexical, une closure les rend inaccessibles directement depuis l’extérieur. Seules les fonctions définies dans ce même champ peuvent les lire ou les modifier. C’est le fondement du module pattern.
function createCounter() {
let count = 0; // variable privée, inaccessible de l'extérieur
return {
increment() {
count += 1;
return count;
},
decrement() {
count -= 1;
return count;
},
value() {
return count;
},
};
}
const counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.value(); // 2
counter.count; // undefined, impossible d'y toucher directement
count n’est exposé par aucun chemin direct : on ne peut interagir avec lui qu’au travers des fonctions retournées par createCounter. Les closures garantissent ainsi une véritable encapsulation, où l’état interne reste protégé et où l’interface publique reste maîtrisée.
En résumé
Currying, application partielle, fonctions d’ordre supérieur, callbacks, memoization, encapsulation : six piliers du JavaScript moderne qui partagent une même fondation. À chaque fois, le besoin est identique, conserver l’accès à un contexte lexical au-delà de la durée de vie de la fonction qui l’a créé, et la réponse est la même : une closure.
C’est ce qui fait des closures bien plus qu’un point de théorie. Les comprendre, ce n’est pas seulement réussir une question d’entretien : c’est saisir le mécanisme qui sous-tend une grande partie des patterns que vous manipulez déjà, et gagner la capacité de les concevoir vous-même en connaissance de cause.