logo

 

Les nouveautés d’ECMAScript 2022

ECMAscript 2022

Les nouveautés d’ECMAScript 2022

Chaque année, la spécification ECMAScript, derrière le langage JavaScript, s’enrichit de nouvelles fonctionnalités, en sortant une nouvelle version autour du mois de juin. Si la version finale de la spécification d’ECMAScript 2022 ne sortira pas avant plusieurs semaines, celle-ci est déjà gelée, et nous savons quelles nouveautés devraient investir ECMAScript cette année. Nous vous proposons donc un tour d’horizon des fonctionnalités apportées par ES2022. Cette année, ce sont 8 propositions qui rejoignent la spécification derrière le langage JavaScript, dont certaines très attendues, parfois même depuis 2015.

Contenu

Les champs de classe

La première proposition intégrée dans ES2022, et sans-doute l’une des plus attendues, couvre en réalité trois sous-propositions, très proches et interdépendantes, concernant les classes. En effet, lors de leur arrivée en 2015, un certain nombre de critiques ciblaient la simplicité de la fonctionnalité vue par ECMAScript, notamment l’absence des qualificateurs de portée que l’on retrouve habituellement dans la plupart des langages orienté-objets, tels que Java, PHP ou C++ (par exemple private, public ou protected). L’absence de syntaxe dédiée aux variables et fonctions statiques de la classe était également décriée. Avec cette proposition, ces deux manques seront comblés, 7 ans après l’arrivée des classes dans le langage.

Déclaration des attributs

Dans la plupart des langages utilisant la programmation orientée objets par classe, y compris ceux avec un typage dynamique, comme PHP, il est nécessaire de définir les attributs de chaque classe en haut de celle-ci, avant tout usage. En ECMAScript, ce n’est pas nécessaire, et ça n’était même pas possible, jusqu’à l’arrivée d’ES2022. En effet, le seul moyen de définir un attribut était de lui affecter une valeur, souvent dans le constructeur. Depuis ES2022, il est possible de définir un attribut directement dans le corps de la classe, ce qui peut même nous dispenser d’écrire un constructeur, à l’image de nombreux langages. Ainsi, un code qu’on aurait écrit ainsi en 2015 :

class Counter {
  constructor() {
    this.count = 0
  }

  incrementCount() {
    this.count++
  }
}

peut maintenant s’écrire ainsi, avec une sémantique quasiment similaire :

class Counter {
  count = 0

  incrementCount() {
    this.count++
  }
}

L’unique différence est dans un cas d’héritage, où, un membre de la classe parente qui porterait le même nom sera toujours redéfini. Cela signifie essentiellement que le setter de la classe parente ne sera pas appelé, mais sera remplacé. Par exemple, dans le cas suivant, rien ne s’affichera dans la console à la création d’une instance de B, et le setter disparaîtra, remplacé par la propriété, ce qui n’aurait pas été le cas lors d’une définition « classique » par le constructeur.

class A {
  set x(value) {
    this.x = value
  }
}

class B extends A {
  x = 1
}

class C extends A {
  constructor() {
    super()
    x = 2
  }
}

const b = new B() // Rien dans la console
b.x // 1

const c = new C() // 2 apparaît dans la console
c.x // undefined (on n'a pas de getter ici)

Notez enfin que cette syntaxe reste facultative, et que vous pourrez toujours utiliser les membres de classe sans les déclarer, sauf dans un cas spécifique : les membres privés.

Membres privés

En 2015, l’approche d’ECMAScript se rapprochait de celle que l’on pouvait retrouver en Python, c’est-à-dire une convention de nommage pour prévenir l’utilisateur de nos classes que certains membres étaient privés. Ce choix est, notamment, lié au fait que la plupart des langages qui intègrent des qualificateurs de portée ne sont en réalité pas beaucoup plus qu’une indication, puisqu’il est souvent possible de les contourner, par le biais d’API de métaprogrammation. Le TC39, qui se charge de la spécification d’ECMAScript, a donc préféré fournir une implémentation de « Véritables » membres privés, qui ne peuvent pas être lus ou modifiés depuis l’extérieur de la classe. Cela s’explique par le besoin, dans le contexte du développement web, de sécurité et de protection des informations, puisqu’il est très fréquent d’intégrer, sur son application, des scripts sur lesquels on n’a pas la main, comme des extensions navigateur ou des régies publicitaires.

C’est finalement en ES2022 que cette fonctionnalité, attendue de longue date, finira par arriver officiellement (même si elle était déjà disponible dans la plupart des environnements depuis un bon moment). Pour définir un attribut privé, il faut le faire de la même manière qu’un attribut public, en le déclarant dans le corps de la classe (ce qui est rendu nécessaire pour garantir la sécurité et l’encapsulation au sein du moteur). La seul différence avec un attribut public classique, c’est qu’il faut faire précéder son nom par le symbole#. Ce sigil est nécessaire à chaque utilisation du membre privé concerné, que ce soit en lecture ou en écriture.

class MyApiCaller {
  #apiKey

  constructor(apiKey) {
    this.#apiKey = apiKey
  }

  set apiKey(apiKey) {
    this.#apiKey = apiKey
  }

  runApiCall() {
    return fetch(this.#getApiCallUrl())
  }

  #getApiCallUrl() {
    return `https://my.api.tld/?key=${this.#apiKey}`
  }
}

Dans l’exemple ci-dessus, la clé d’API est totalement inaccessible depuis l’extérieur du corps de la classe. En revanche, une autre instance peut avoir accès aux membres privés d’une autre. Dans l’exemple ci-dessus, on voit que notre membre #apiKey est différent de apiKey qui est notre accesseur. Cela met en lumière l’importance du sigil dans la définition du membre privé, et cela permet également de voir en un coup d’œil qu’on a affaire à un membre privé. Toutefois, il faut garder en tête que le « nom » de notre attribut n’est pas non-plus #apiKey. Y accéder dynamiquement est, par exemple, totalement impossible. C’est un besoin relativement rare quand on fait l’usage de membres privés, mais il faut garder cette limite en tête lorsqu’on les utilise.

class MyApiCaller {
  #apiKey

  access1(fieldName = 'apiKey') {
    return this.#[fieldName] // Syntax error
  }

  access2(fieldName = '#apiKey') {
    return this[fieldName] // undefined
  }

  access3(fieldName = 'apiKey') {
    return this[fieldName] // undefined
  }
}

Les membres statiques

Un autre manque des classes apportées par ECMAScript est la présence d’une syntaxe de déclaration de membres statiques, comme dans la plupart des langages orientés objets par classe. Les membres « statiques » ont toujours existé, mais leur déclaration était assez peu concise, et déroutante pour quelqu’un venant d’autres langages plus conventionnels comme Java. En effet, le seul moyen de déclarer des membres de classe était de le faire après avoir déclaré la classe, en lui affectant les propriétés manquantes comme à un autre objet.

class MyExampleClass {
  constructor() {
    this.constructor.incrementInstances()
  }
}

MyExampleClass.instancesCount = 0
MyExampleClass.incrementInstances = function() {
  this.instancesCount++
}

const example = new MyExampleClass()
MyExampleClass.instancesCount // 1

Certes, ça fonctionne, mais c’est assez peu clair. Dans ES 2022, il est désormais possible d’utiliser le mot-clé habituel static, et tout saisir dans le corps de notre classe. Ainsi, le code suivant sera équivalent à celui juste au-dessus.

class MyExampleClass {
  static instancesCount = 0

  constructor() {
    this.constructor.incrementInstances()
  }

  static incrementInstances() {
    this.instancesCount++
  }
}

const example = new MyExampleClass()
MyExampleClass.instancesCount // 1

Bien-entendu, cette syntaxe est compatible avec toutes les fonctionnalités des classes, comme les accesseur ou les membres privés.

Indices de correspondance des expressions régulières

Lors de l’utilisation de la méthode match des expressions régulières d’ECMAScript, on obtient un objet, contenant des informations sur la correspondance entre la chaîne de caractères et l’expression, notamment la liste des sous-chaînes de caractères identifiées. Cependant, cet objet avait, jusqu’à présent, une limite : il ne contenait que les sous-chaînes en elles-mêmes, ainsi que l’index de début de la correspondance de l’entièreté de l’expression régulière Mais il manquait une information : les indices de début et de fin des sous-chaînes. Si cette information est bien-souvent non-nécessaire, elle reste importante pour quelques besoins, tels que la coloration syntaxique de code TextMate.

ES2022 intègre donc une proposition visant à rendre disponible cette information, directement dans l’objet renvoyé par les méthodes String.prototype.match et Regexp.prototype.exec. Pour ce faire, un nouveau flag est introduit : d. Une fois ce flag spécifié dans votre expression régulière, les indices seront disponibles directement dans l’objet, dans la propriété bien-nommée indices. Comme le résultat d’une expression régulière, cette propriété prend la forme d’un tableau, contenant chaque correspondance, de la même manière qu’une expression régulière classique (avec, en premier, la correspondance complète, puis chacun des groupes capturés). Les éléments de ces tableaux prennent eux-même la forme de tableaux, de deux éléments. Le premier correspond à l’index de début, le second à l’index suivant l’index de fin (correspondant donc à la longueur de la chaîne quand on applique cela sur la chaîne complète).

const regex = /adikt(s?)/d

const string = 'adikts'

const result = regex.exec(string)

result

result[0][0] // Vaut 0 car correspond au début de la chaîne qui est match par la regex
result[0][1] // Vaut 6 car correspond à l'index de fin de la chaîne (donc le dernier index + 1)

result[1][0] // Vaut 5 car correspond à l'index de la correspondance au caractère s dans la chaîne.
result[0][1] // Vaut 6 car correspond à l'index de fin de la correspondance au caractère s, situé à la fin de la chaîne.

Bien-sûr, cette fonctionnalité est compatible avec les groupes de capture nommés, introduits dans ES2018. Ainsi, si vous utilisez une capture nommée avec la syntaxe associée dans votre expression régulière, la propriété indices contiendra elle-même une propriété groups contenant chacune des correspondances nommées, indexée par son nom, toujours de la même manière que l’objet de correspondance. Pour reprendre notre exemple, avec une capture nommée, cela donnerait un résultat plus lisible et clair.

const regex = /adikt(?<S>s?)/d

const string = 'adikts'

const result = regex.exec(string)

result

result.indices.groups.S[0] // 5 car correspond à result.indices[1][0]
result.indices.groups.S[1] // 6 car correspond à result.indices[1][1]

Top-level await

Depuis leur introduction en 2015, les ES Modules de Javascript, bien que fort pratiques, restaient complexes à utiliser dans un cas spécifique, lorsque notre module avait besoin d’attendre un traitement asynchrone avant d’être disponible. En effet, dans certaines situations, il pouvait être nécessaire d’attendre la résolution d’un traitement asynchrone avant d’avoir accès à un ou plusieurs exports des modules. Les solutions trouvées pour contourner le problème étaient nombreuses, mais pas toujours simples à utiliser, peu transparentes et assez différentes, obligeant donc l’utilisateur du module à se renseigner sur les conventions utilisées par celui-ci, et à adapter son code en fonction. Par exemple, une solution était d’exporter directement une promesse, résolue avec l’export quand le traitement est terminé.

export default (async () => {
  const modulePath = await asynchronousFindPathModule()
  const module = await import(modulePath)
  return {
    module
  }
})()

Ce qui nécessite d’attendre la résolution de la promesse avant de se servir du module, et donc d’avoir un traitement spécifique dans son propre module, pouvant, ensuite, obliger à un autre export asynchrone.

Pour remédier à ce problème, ES2022 apporte une petite fonctionnalité, très simple, mais très utile : l’await au premier niveau. Ainsi, dans le contexte des ES Modules, il devient possible d’utiliser le mot-clé await, de la même manière qu’à l’intérieur d’une fonction async, ce qui simplifie le code, et offre une manière générique de procéder. Notre module du dessus s’écrirait donc comme ceci :

const modulePath = await asynchronousFindPathModule()
export const module = await import(modulePath)

Côté utilisateur, c’est complètement transparent, vous n’avez rien besoin d’ajouter. Dans le cas où vous importez votre module de façon synchrone, avec le mot-clé import, d’une façon classique, le code de votre module commencera à s’exécuter une fois que le code des modules, y compris la partie asynchrone, aura été exécuté. Dans le cas d’un import dynamique, avec le mot-clé import utilisé comme une fonction, cela ne change rien non-plus, la promesse renvoyée par import sera résolue quand tous les traitements asynchrone de la bibliothèque importée seront achevés.

Opérateur in pour les membres privés

En lien avec les membres privés, qui sortent également dans ES2022, une des propositions s’est penchée sur une problématique rare, mais néanmoins importante. En effet, dans quelques cas, il peut être utile de pouvoir vérifier si un objet contient bien un membre privé, plutôt que de provoquer une erreur. Un des cas d’utilisation est le cas où l’on souhaite s’assurer qu’un objet passé en paramètre comporte bien un certain membre privé, depuis une méthode statique, ou depuis un autre instance, par exemple quand on veut accéder à ce membre. Une solution proposée peut être de renvoyer un booléen en fonction d’un try/catch :

class A {
  #a = 1

  static isA(obj) {
    try {
      obj.#a
      return true
    } catch {
      return false
    }
  }
}

const a = new A()
const notA = {}

A.isA(a) // true
A.isA(notA) // false

Cette solution comporte quelques inconvénients, notamment une lisibilité réduite, ou la difficulté de gérer le cas où une erreur est soulevée par autre chose que l’absence du membre privé (par exemple, dans le cas d’un getter). Ainsi, cette nouvelle fonctionnalité ajoute une nouvelle syntaxe, permettant à l’opérateur in de vérifier la présence d’un membre privé dans un objet, d’une façon plus claire et plus concise.

class A {
  #a = 1

  static isA(obj) {
    return #a in obj
  }
}

const a = new A()
const notA = {}

A.isA(a) // true
A.isA(notA) // false

Évidemment, cet opérateur ne permet pas de découvrir les membres privés d’autres classes. Tenter d’utiliser un membre privé qui n’appartient pas à notre classe avec l’opérateur in résultera directement en SyntaxError.

class A {
  #a = 1

  static isA(obj) {
    return #b in obj // SyntaxError: Private field "#b" must be declared in an enclosing class (5:11)
  }
}

Notez enfin que vous ne pouvez pas utiliser cela pour identifier des champs privés d’autres classes qui auraient le même nom que les vôtres. L’opérateur renvoie false dans ces cas-là.

class A {
  #a = 1

  static isA(obj) {
    return #a in obj
  }
}

class B {
  #a = 2
}

const a = new A()
const b = new B()

A.isA(a) // true
A.isA(b) // false

Méthode at() sur les indexables natifs

En ECMAScript, contrairement à de nombreux langages comme Python, l’opérateur d’indexation [] ne permet pas de passer un index négatif, pour accéder aux derniers éléments de la collection. Il s’agit d’une fonctionnalité demandée depuis longtemps sur ECMAScript. Cependant, l’opérateur d’indexation du langage étant également utilisé pour des objets, il n’était pas possible de lui ajouter un nouveau fonctionnement, car les nombres négatifs sont actuellement convertis en chaîne de caractères dans ces contextes. À cette solution, il a été préférée une nouvelle méthode : at().

Jusqu’à présent, la bonne pratique, en ECMAScript, pour l’accès aux derniers éléments d’un tableau, était d’utiliser un index relatif à la longueur du tableau, comme ceci :

const tableau = ['a', 'd', 'i', 'k', 't', 's']
tableau[tableau.length - 1] // s

Cette solution a, toutefois, plusieurs problèmes. Elle n’est pas des plus concises et n’est pas utilisable dans tous les cas. Par exemple, cela oblige à stocker le tableau retourné par une fonction dans une variable temporaire avant de s’en servir. Une proposition a donc vu le jour pour fournir une méthode native permettant d’avoir ce comportement, au lieu de l’opérateur d’indexation. Il suffit donc de lui passer l’index, positif ou non, de l’élément que l’on souhaite récupérer, et on a alors un comportement proche de celui que l’on peut retrouver dans des langages comme Python.

const tableau = ['a', 'd', 'i', 'k', 't', 's']
tableau.at(-1) // s
tableau.at(-3) // k

Comme pour l’opérateur d’indexation, si vous dépassez la longueur de votre indexable, d’un côté comme de l’autre, la fonction renverra simplement undefined. Évidemment, les tableaux ne sont pas les seules classes à pouvoir bénéficier de cette fonction, commune à tous les indexables natifs, ce qui inclut, entre autres, String et TypedArray.

const tableau = 'adikts'
tableau.at(-1) // s
tableau.at(-3) // k

Fonction Object.hasOwn()

En ECMAScript, pour savoir si un objet possède une propriété en particulier, il existe la fonction hasOwnProperty() définie directement dans le prototype d’Object, et donc, en principe, héritée par tous les objets. Elle est donc prévue pour fonctionner de cette manière :

const objet = {
  a: 1,
  b: 2,
}

objet.hasOwnProperty('a') // true
objet.hasOwnProperty('c') // false

Toutefois, cette manière d’utiliser cette fonction a d’importantes limites. Tout d’abord, les objets n’héritent pas tous d’Object, et certains n’ont aucun prototype (ceux créés via Object.create(null), par exemple). Et surtout, il n’est pas impossible que, dans l’objet lui-même, hasOwnProperty soit redéfini. C’est encore plus vrai dans un contexte web, où on a rapidement affaire à des informations transmises plus ou moins directement par l’utilisateur, ou, plus généralement, par le réseau, et donc auxquels on ne peut pas nécessairement faire confiance. Une telle utilisation peut rapidement amener des bugs ou des failles, ce qui amène les principaux linters à considérer cette syntaxe comme une erreur, et à préférer utiliser directement la fonction issue du prototype d’Object, en utilisant la méthode call :

const objet = {
  a: 1,
  b: 2,
}

Object.prototype.hasOwnProperty.call(objet, 'a') // true
Object.prototype.hasOwnProperty.call(objet, 'c') // false

Cette solution est, certes, plus sûre, mais est très verbeuse, et pas claire, en particulier pour un développeur débutant, puisqu’elle nécessite de comprendre plusieurs concepts pas nécessairement utiles dans ce contexte. C’est dans ce contexte qu’une des propositions intégrées dans ES2022 offre Object.hasOwn. Comme les autres fonctions sur les objets, elle n’est plus attachée au prototype d’Object, mais à Object lui-même, et peut donc être appelé directement depuis celui-ci, faisant gagner en simplicité et en clarté au code, et nous permettant de nous affranchir de la spécificité de hasOwnProperty.

const objet = {
  a: 1,
  b: 2,
}

Object.hasOwn(objet, 'a') // true
Object.hasOwn(objet, 'c') // false

Bloc statique de classe

Dans la lignée des ajouts d’ES2022 aux classes, le bloc statique se révèle lui-aussi intéressant. Il permet de définir un bloc de code (ou plusieurs) exécuté au moment de la définition de la classe, comme la définition des attributs statiques. Cela permet donc de déplacer à l’intérieur de la classe la logique qui, jusqu’à présent, avait sa place en dehors de son code. Par exemple dans le cas où une exception peut être soulevée lors de la définition de la classe, la solution aurait été celle-ci :

class A {
  static x = 1
  static y
  static z
}

try {
  const { y, z } = getYZValueFromX(A.x)
  A.y = y
  A.z = z
} catch {
  A.y = 0
  A.z = 0
}

Les blocs statiques permettent donc de rendre ces cas plus simples et clairs, en définissant ce code directement à l’intérieur du corps de la classe, grâce au mot-clé static immédiatement suivi d’accolades :

class A {
  static x = 1
  static y
  static z

  static {
    try {
      const { y, z } = getYZValueFromX(this.x)
      this.y = y
      this.z = z
    } catch {
      this.y = 0
      this.z = 0
    }
  }
}

Cela permet donc de référencer notre constructeur directement via l’identifiant this, mais aussi d’accéder aux membres privés de notre classe, ce qui peut permettre certaines choses qu’il est impossible de faire autrement. Par exemple définir une fonction permettant de renvoyer un attribut privé, ce qui peut être utile pour donner accès à l’intérieur d’une classe à une fonction du même module, comme ci-dessous.

let SecurityClient, APIClient

{
  let getSecurityClientToken

  SecurityClient = class SecurityClient {
    #token

    static {
      getSecurityClientToken = (object) => object.#token
    }
  }

  APIClient = class APIClient {
    #token
    constructor(securityClient) {
      this.#token = getSecurityClientToken(securityClient)
    }
  }
}

Notez que vous pouvez avoir plusieurs blocs statiques dans une même classe. Ils seront alors résolus dans l’ordre du code, classiquement, de haut-en-bas. Notez également que, bien que this soit utilisable dans ce contexte, ce n’est pas le cas de super. Tenter de l’utiliser provoquera donc une SyntaxError.

Cause d’erreur

Dans ECMAScript, le type Error est utilisé pour représenter une erreur survenue au cours de l’exécution du programme. Cela peut aller d’une erreur de logique (par exemple une TypeError) à une erreur réseau. Les moteurs Javascript, eux-mêmes, soulèvent un certain nombre d’erreurs internes quand certains problèmes surviennent. Usuellement, on traite ces erreurs, pour une solution de repli, ou alors en renvoyant l’erreur à l’utilisateur de notre code. Cependant, dans le cas d’une erreur interne, on souhaite souvent ne pas renvoyer directement cette erreur, mais l’encapsuler, pour y ajouter des éléments de contexte, et éventuellement cacher des informations qui n’auraient pas lieu d’être (notre frontend n’a, par exemple, clairement pas besoin de savoir que l’on ne parvient pas à se connecter à notre base de données). On renvoie donc une autre erreur, mais il est souvent utile de garder une référence vers l’erreur « originelle », à des fins de debug.

Plusieurs solutions existent, mais aucune n’est réellement satisfaisante. Certaines préconisent de simplement concaténer le message de l’erreur originelle à celui de la nouvelle erreur, d’autres solutions nécessitent de créer une propriété directement sur une erreur, voire de créer une classe spécifique, héritant de Error, et contenant un attribut cause. Pour régler ce problème assez commun, ES2022 apporte une modification directement à la classe Error elle-même, et à son constructeur.

En effet, le deuxième paramètre du constructeur Error est un objet optionnel, avec plusieurs propriétés, utilisé, jusqu’à présent, uniquement sur Firefox. À partir d’ECMAScript 2022, il devient donc possible d’ajouter un attribut cause à cet objet, contenant l’erreur d’origine, qui peut alors être vue par l’utilisateur final, comme attribut de l’erreur elle-même, facilitant donc le logging, et évitant donc de multiplier les solutions différentes à un même problème.

async function transferData(from, to) {
  const downloadedResource = await fetch(from)
    .catch((err) => {
      throw new Error('Downloading of data failed', {
        cause: err,
      })
    })
  
  return fetch(to, {
    method: 'POST',
    body: downloadedResource,
  }).catch((err) => {
    throw new Error('Uploading of data failed', {
      cause: err,
    })
  })
}

try {
  await transferData('from', 'to')
} catch (e) {
  console.error(e) // Error: Downloading of data failed
  console.error('caused by', e.cause) // Caused by TypeError: Failed to fetch
}

Conclusion

Voilà pour cette présentation des différentes fonctionnalités apportées par ECMAScript 2022, que vous pouvez utiliser dès à présent dans vos navigateurs et node.js, chacune étant compatible avec node.js 16, actuelle LTS, à l’exception notable des blocs statiques, pris en charge seulement à partir de node.js 17. Vous pouvez également utiliser des outils comme Babel pour en profiter dans des contextes plus anciens. Notre regard se tourne maintenant vers les fonctionnalités qui seront annoncées pour la prochaine version du standard : ES 2023, qui devrait être finalisé dès mars de l’an prochain !

Sources

La plupart des exemples sont inspirés directement des proposals sur github.