Fermetures, variables privées et # : ce que chacun protège en TypeScript et JavaScript
Le mot-clé private en TypeScript constitue une vérification au moment de la compilation, les champs chiffrés sont appliqués en temps de exécution, et les closures capturent le contexte lexical. Une comparaison des fonctions respectives de chacun d’eux.
Sources: Closures vs Private in TypeScript: If We Have private and #, Why Do We Still Need Closures?
TypeScript dispose de la clé private et JavaScript utilise # ; il est donc légitime de se demander pourquoi des closures sont encore nécessaires pour protéger les variables. La réponse semble évidente tant que l’on ne examine pas le comportement de JavaScript en temps de exécution, et c’est alors que les choses deviennent plus intéressantes. Les closures viendront plus tard. D’abord, que fait réellement private dans TypeScript ?
private en TypeScript
Prenons une classe disposant d’une propriété privée :
class User {
private secret: string = "hidden";
}
Créez maintenant une instance et lisez la propriété depuis l’extérieur de la classe :
const me = new User();
console.log(me.secret);
La phrase clé est celle-ci :
console.log(me.secret);
Le compilateur, tsc, le rejette avec une erreur, ce qui fait que la variable semble protégée. Le problème, c’est que private est principalement une fonctionnalité du système de types. Lorsque TypeScript se transforme en JavaScript, ce mot-clé ne subsiste pas en tant que mécanisme de confidentialité en temps de exécution. Cette source :
class User {
private secret: string = "hidden";
}
peut être émis de manière approximative comme suit :
class User {
constructor() {
this.secret = "hidden";
}
}
À ce stade, JavaScript n’a aucune idée que secret devait rester privé, puisqu’il s’agit d’une propriété ordinaire. Ainsi :
const me = new User();
console.log(me.secret);
pourra imprimer :
hidden
La distinction est importante. Ce que private indique au compilateur, c’est que le code utilisant ce type ne doit pas accéder à la propriété. Cela n’indique pas au système en temps de exécution que cette propriété doit être inaccessible, et il s’agit là de promesses très différentes.
Ce que # change
TypeScript prend également en charge les champs de classe privés natifs de JavaScript :
class User {
#secret: string = "hidden";
}
Essayer de lire le champ depuis l’extérieur échoue maintenant de manière différente :
const me = new User();console.log(me.#secret);
Ce n’est pas seulement un avertissement de compilateur. Le champ est également privé en temps de exécution, car la syntaxe # crée un véritable champ de classe privé. Le lire depuis l’extérieur de la classe est interdit :
console.log(me.#secret);
Et lire une propriété simple du même nom ne permet pas non plus d’y accéder :
console.log(me.secret);
Puisque #secret n’est pas une propriété ordinaire nommée secret, il peut être tentant de l’imaginer comme une clé de chaîne :
me["#secret"]
Mais cela ne fonctionne pas. Le # n’est pas une convention de nommage, car le temps d’exécution dispose d’une notion réelle de champs privés. Nous avons donc deux éléments distincts. Le premier est principalement imposé par le système de types :
private secret: string;
et le deuxième par la sémantique de JavaScript :
#secret: string;
Où s’appliquent les closures
Au moment où il n’existait pas encore de champs privés dans les classes, les développeurs avaient toujours besoin de données auxquelles on ne pouvait pas accéder directement depuis l’extérieur, et l’un des outils les plus puissants déjà disponibles dans le langage était la closure. Voici une fonction usine :
function createUser() {
let secret = "hidden";
Il renvoie un objet qui expose un seul accesseur :
return {
getSecret() {
return secret;
}
};
}const user = createUser();console.log(user.getSecret());
L’appel de cet accesseur affiche :
hidden
Remarquez ce qui manque. Il n’y a pas d’accès à une propriété de cette manière :
user.secret
L’objet retourné ne possède même pas de propriété secret. La variable se trouve dans l’environnement lexical créé lors de l’exécution de createUser(), et la fonction getSecret() retournée se souvient de cet environnement. C’est là l’essence d’une fermeture : une fonction conserve un accès aux variables de son contexte de création, même après que la fonction externe ait terminé. Après cette appel :
const user = createUser();
La fonction externe est terminée. On s’attendrait normalement à sa variable locale :
let secret = "hidden";
disparaître. Pourtant, getSecret() parvient encore à y accéder, car il se ferme sur secret, d’où provient ce nom.
État privé sans le rendre accessible
Les closures peuvent conserver un état privé sans variable exposée. Un compteur en est un bon exemple :
function myCounter() {
let count = 0;
return {
increment() {
count++;
}, getCount() {
return count;
}
};
}const counter = myCounter();counter.increment();
counter.increment();console.log(counter.getCount());
Appeler la méthode d’incrémentation deux fois puis demander le compteur affiche :
2
alors que celui-ci ne fonctionne pas :
console.log(counter.count);
car count n’est pas une propriété du compteur. C’est une variable située dans l’environnement lexical capturé. L’objet fournit le comportement, tandis que l’état réel est stocké ailleurs, ce qui constitue une forme d’encapsulation issue du scoping lexical.
# est-il simplement meilleur ?
Pas nécessairement. Ils résolvent des problèmes similaires mais ne représentent pas la même fonctionnalité. Voici la version basée sur une classe :
class User {
#secret = "hidden";
getSecret() {
return this.#secret;
}
}
Dans ce cas, #secret représente explicitement un état privé de la classe, appartenant à l’objet, et le temps d’exécution en a connaissance. Dans la version avec fermeture :
function createUser() {
let secret = "hidden";
return {
getSecret() {
return secret;
}
};
}
La variable n’est absolument pas une propriété de l’objet et se trouve dans l’environnement lexical qui l’entoure. Les mécanismes sont fondamentalement différents. Un champ # indique que cet objet possède un champ privé, tandis qu’une fermeture indique que cette fonction peut utiliser l’état de son contexte immédiat. Cette différence influence la conception des API.
État partagé entre fonctions
Les fermetures offrent plus que simplement la confidentialité : plusieurs fonctions peuvent partager un même état. Un compte bancaire en est un exemple :
function createBankAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) {
balance += amount;
}, withdraw(amount) {
balance -= amount;
}, getBalance() {
return balance;
}
};
}
Voici à quoi cela ressemble lors de son utilisation :
const account = createBankAccount(1000);
account.deposit(500);console.log(account.getBalance());
Et le résultat est :
1500
Aucune propriété publique à lire :
account.balance
deposit(), withdraw() et getBalance() accèdent tous à la même variable en scope :
balance
C’est l’une des raisons pour lesquelles les fermetures sont si importantes en JavaScript. Elles ne sont pas seulement un moyen de cacher des variables, mais aussi une façon de conserver l’état entre différentes appels.
Corriger une première supposition
Un résumé tentant serait que les fermetures fournissent le mécanisme caché en temps de exécution, tandis que private et # ne servent qu’à afficher des avertissements en temps de compilation. C’est proche, mais incorrect. private en TypeScript sert principalement à vérifier l’accès en temps de compilation, tandis que #private en JavaScript impose la confidentialité en temps de exécution. Les fermetures ne sont ni un autre mot-clé, ni purement liées au temps de compilation. Elles découlent du scoping lexical, qui permet aux fonctions de conserver un accès aux variables de leur environnement immédiat. Cela peut permettre l’encapsulation, mais les fermetures ont une portée bien plus large que la simple confidentialité. Une usine de fonctions illustre leur utilisation sans qu’il y ait de notion de confidentialité :
function createMultiplier(x) {
return function (y) {
return x * y;
};
}
const double = createMultiplier(2);console.log(double(10));
Ici, la fonction retournée se souvient de x, et c’est ce qu’on appelle une clôture. Le même mécanisme est à l’origine de l’état privé, des usines de création, des appels en retour, des gestionnaires d’événements, de la mémorisation et bien plus encore.
Un modèle mental des trois
Pour TypeScript, private :
class User {
private secret = "hidden";
}
Interprétez cela comme le système de types qui vous empêche d’accéder au membre. Pour JavaScript #private :
class User {
#secret = "hidden";
}
Interprétez cela comme le temps d’exécution traitant ce champ comme véritablement privé. Pour une clôture :
function createUser() {
let secret = "hidden";
return {
getSecret() {
return secret;
}
};
}
Considérez-le comme une fonction qui mémorise les variables de l’encadrement dans lequel elle a été créée. C’est ce dernier point qui est essentiel. Les fermetures ne sont plus obsolètes depuis l’apparition de #, car #private est une fonctionnalité conçue pour les champs privés des classes, tandis que les fermetures résultent de l’encadrement lexical et des fonctions de première classe, et servent à de nombreuses fins sans rapport avec la confidentialité.
Dans les détails
Un point subtil reste à souligner. Il serait incorrect de dire que private et # se transforment tous deux en propriétés ordinaires de JavaScript. Cela vaut pour private en TypeScript dans le résultat généré habituel, mais pas pour les champs natifs #private :
class User {
#secret = "hidden";
}
Cela fait appel à une fonctionnalité en temps de exécution. Selon la cible de compilation, TypeScript peut conserver cette syntaxe ou la simplifier pour des versions plus anciennes de JavaScript, mais les sémantiques du langage exigent néanmoins un comportement de champ privé. Par conséquent, ne mettez pas private et #private dans la même catégorie : ils se ressemblent en TypeScript mais sont appliqués différemment, et les closures représentent un mécanisme complètement distinct.
Alors, les closures sont-ils encore nécessaires ?
Oui, et une question plus pertinente que celle de savoir pourquoi nous avons besoin de closures malgré private et # est de connaître le problème que résout chacun de ces mécanismes. Chacun répond à un besoin différent : private limite l’accès au sein du vérificateur de types, #private fait en sorte que le temps d’exécution lui-même impose la confidentialité des champs, tandis que les closures permettent aux fonctions de continuer à utiliser le contexte dans lequel elles ont été créées. Une fois cela clair, les closures cessent de paraître comme une vieille solution de contournement pour les variables privées et révèlent leur véritable rôle en tant que bloc de construction fondamental du modèle de programmation de JavaScript. Apprendre une fonctionnalité plus récente ne rend pas un concept ancien irrelevant ; cela vous permet plutôt de poser une question plus précise à son sujet, à savoir pourquoi ce mécanisme était utile en premier lieu. Pour les closures, la réponse va bien au-delà du simple fait de cacher une variable.