Cet article a pour but de présenter un peu plus posément et en détail les Promises (ou Promesses en français), un concept qui avait été déjà abordé dans un article que j'avais traduit en 2015 : Le futur de l'asynchrone en JavaScript.
Je pense que c'est d'autant plus intéressant que la norme ES2015 (ou ES6) s'est depuis largement diffusée côté navigateurs, serveurs, transpileurs et développeurs au point que les Promises sont devenues un élément central de la programmation asynchrone sur lequel d'autres fonctionnalités de la norme ECMAScript se construisent, comme async/await.
Bien que les exemples dans cet article soient rédigés en TypeScript, les différences avec JavaScript ne concernent que les annotations de typage qui peuvent être vues ici comme de la documentation supplémentaire. Cela ne devrait pas gêner la compréhension de quelqu'un habitué à JavaScript.
1. Traitements asynchrones et callbacks
JavaScript est un langage fonctionnel reposant sur une architecture asynchrone (cf. Les bases de l'asynchrone en JavaScript). Il n'est donc pas surprenant qu'historiquement, l'appel à des traitements asynchrones se faisait à l'aide de callbacks, ces fonctions passées en argument, permettant de poursuivre l'exécution du programme suite à la réalisation du traitement asynchrone.
Il existe de nombreuses fonctions asynchrones dans les API JavaScript, la plus connue étant sans doute XMLHttpRequest.send(), mais pour des questions pratiques et pédagogiques, nous allons nous construire notre propre fonction asynchrone qui simulera la récupération d'une donnée sous la forme d'un nombre.
Cette fonction asynchrone faite maison est nommée de façon très imaginative getData(). Elle produit un nombre aléatoire qui sera ensuite transmis à la fonction callback préalablement passée en argument. Et pour simuler les erreurs éventuelles pouvant survenir durant un traitement asynchrone, cette fonction getData() « plante » aléatoirement dans 25 % des cas.
Code typescript : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 | type Callback = (error?: Error, data?: number) => void; function getData(callback: Callback) { setTimeout(function setTimeoutCB(counter: number) { if (Math.random() < 0.25) { callback(new Error("Error in retrieving data.")); } else { let data = Math.random(); callback(undefined, data); } }, 500); } // getData |
Définition de la fonction asynchrone getData()
On notera que cette fonction asynchrone getData() utilise une convention largement répandue en JavaScript qui est souvent nommée le callback pattern reposant sur les quatre principes suivants :
- une seule fonction callback gérant à la fois le succès et l'échec ;
- la fonction callback est appelée une et une seule fois. Soit en cas de succès ou d'échec ;
- la fonction callback est le dernier argument de la fonction asynchrone ;
- le premier paramètre de la fonction callback représente l'erreur, le second représente le résultat en cas de succès : function (error, result) { ... }
Dans le cadre de cet exemple, la récupération des données se fait via un appel à cette fonction. Nous supposerons ici trois traitements successifs, process1(), process2() et process3(), chacun récupérant une donnée à l'aide de getData(). Voici à quoi pourrait ressembler la fonction du premier traitement :
Code typescript : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 | function process1() { getData((error, data) => { if (error) console.log(error); else { console.log("Process 1:", data); process2(); } }); } // process1 |
Définition du premier traitement
Il s'agit évidemment d'un exemple minimaliste, car on remarque que cette fonction process1() ne fait pas grand-chose à part gérer un éventuel cas d'erreur, afficher la donnée récupérée via getData() et appeler le traitement suivant, process2().
Les fonctions pour les deux autres traitements sont très similaires :
Code typescript : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | function process2() { getData((error, data) => { if (error) console.log(error); else { console.log("Process 2:", data); process3(); } }); } // process2 function process3() { getData((error, data) => { if (error) console.log(error); else { console.log("Process 3:", data); } }); } // process3 |
Définition des deux autres traitements
Code : | Sélectionner tout |
1 2 3 | Process 1: 0.062177133421918995 Process 2: 0.2886812664927907 Process 3: 0.2154450024357153 |
Exemple de sortie sans erreur
Code : | Sélectionner tout |
1 2 3 4 5 6 | Process 1: 0.41092615721662096 Error: Error in retrieving data. at Timeout.setTimeoutCB [as _onTimeout] (callback.js:13:22) at ontimeout (timers.js:386:14) at tryOnTimeout (timers.js:250:5) at Timer.listOnTimeout (timers.js:214:5) |
Exemple de sortie avec erreur
La fonction process2() appelle getData() puis enchaîne sur la fonction process3() qui appelle à son tour getData() pour enfin conclure l'exécution du programme.
Cette façon de programmer des appels à des fonctions asynchrones est très classique. Il se trouve que dans cet exemple, simplifié à l'extrême, cela ne pose pas de problème particulier, mais il suffit que le code soit un peu plus gros, avec des fonctions d'appel dans le désordre et disséminées dans plusieurs fichiers source pour aboutir à ce qui est communément appelé la programmation spaghetti et qui est avec la « pyramide de la mort » (succession excessive d'indentations), le principal symptôme de « l'enfer des callbacks ».
Ça peut vite partir dans tous les sens et peut donner la migraine lorsqu'il s'agit de déboguer ou de retoucher le code.
C'est là qu'entre en jeu un nouveau concept, les Promises.
2. La promesse des Promises
Il y a mille et une manières d'expliquer ce que sont les Promises, concept un peu mystérieux au premier abord. Plutôt que d'employer des métaphores, parfois déroutantes et trompeuses, je pense que le plus simple est de présenter les choses sous un angle concret et de ne pas trop se focaliser sur le terme en lui-même.
Une Promise est la transformation d'une fonction asynchrone en un objet (au sens JavaScript du terme) afin de faciliter la manipulation de ladite fonction asynchrone. Dans le domaine de la programmation, cette transformation d'une fonction en un objet est parfois appelée réification. C'est d'ailleurs un design pattern puissant.
Dans notre exemple, il s'agit donc de transformer notre fonction asynchrone getData() en un objet de type Promise. Pour se faire, la norme ES2015 propose un constructeur de la forme suivante :
Code typescript : | Sélectionner tout |
1 2 3 | new Promise((resolve, reject) => { /* appel à la fonction asynchrone */ }); |
On notera que la forme ci-dessus n'est pas typée pour des raisons de clarté, une version typée est indiquée un peu plus loin.
Le constructeur d'une Promise prend en paramètre une fonction, désignée par convention comme « l'exécuteur ». Cet exécuteur prend deux fonctions en argument : resolve et reject. La fonction resolve() sera appelée en cas de succès de la fonction asynchrone, tandis que la fonction reject() sera appelée en cas d'échec.
Dans le cadre de notre exemple avec getData(), cela pourrait ressembler à ceci :
Code typescript : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 | type PromiseResolve<T> = (value?: T | PromiseLike<T>) => void; type PromiseReject = (error?: any) => void; function getDataPromise() { return new Promise((resolve: PromiseResolve<number>, reject: PromiseReject): void => { getData((error, data) => { if (error) reject(error) else resolve(data) }); }); } // getDataPromise |
Réification de la fonction asynchrone
Ci-dessus, la fonction getDataPromise() génère une Promise de getData(). On peut constater que la fonction callback passée en argument de getData() appelle reject() en cas d'erreur, et resolve() si tout se passe correctement.
Pour appeler notre fonction asynchrone getData() à partir de cet objet généré par le constructeur new Promise(), nous pouvons utiliser une méthode nommée then() de la façon suivante :
Code typescript : | Sélectionner tout |
1 2 3 4 5 6 | getDataPromise().then( data => { // resolve() console.log("Process 1:", data); return getDataPromise(); } ) |
Appel de la fonction asynchrone via la méthode then()
La méthode then() va appeler l'exécuteur de la Promise qui lui-même appellera la fonction asynchrone. Cette méthode then() prend en théorie deux paramètres, le second étant optionnel. Ces paramètres correspondent respectivement à la fonction resolve() et reject() qui seront transmis à l'exécuteur. Dans l'exemple ci-dessus, seul le paramètre correspondant à la fonction resolve() a été spécifié.
À noter qu'il n'est pas obligatoire de spécifier la fonction reject() dans l'appel à la méthode then(), c'est même déconseillé. Si une erreur survient et que la fonction reject() n'est pas spécifiée, alors une exception sera signalée qui pourra être récupérée ultérieurement via une autre méthode catch().
Avant de poursuivre, voici un petit récapitulatif des notions abordées autour des Promises :
- Fonction asynchrone : fonction dont on souhaite obtenir un résultat ;
- Promise : réification de la fonction asynchrone ;
- Exécuteur de la Promise : fonction appelée, entre autres, via la méthode then() de la Promise et chargée d'appeler la fonction asynchrone ;
- resolve : fonction appelée en cas de succès de la fonction asynchrone ;
- reject : fonction appelée en cas d'échec de la fonction asynchrone ;
- then : méthode de la Promise pour appeler l'exécuteur.
Comme on l'a vu précédemment, transformer une fonction asynchrone en Promise (on lit parfois « promissification ») consiste en deux étapes :
- réifier la fonction asynchrone via le constructeur de Promise en prenant soin d'écrire l'exécuteur de la fonction asynchrone de telle sorte qu'il appelle la fonction resolve() en cas de succès, et reject() en cas d'échec ;
- l'appel de la fonction asynchrone peut se faire via la méthode then() de la Promise nouvellement créée. C'est dans l'appel à cette méthode then() que se définissent concrètement les fonctions auxiliaires resolve() et reject(), et qui seront appelées par l'exécuteur de la fonction asynchrone.
3. Composition de Promises
L'état d'une Promise peut évoluer au cours de son existence.
Lors de sa création, une Promise est dans un état « en attente » (pending), sous-entendu, en attente d'être résolue.
Lorsque la méthode then() de cette Promise est appelée, son état devient soit honoré (fullfilled) en cas de succès du traitement asynchrone sous-jacent, ou rompu (rejected) en cas d'échec. Une Promise honorée ou rompue est une Promise acquittée (settled) ou encore résolue, bien que ce dernier terme puisse porter à confusion avec la fonction resolve() qui correspond à une Promesse honorée uniquement.
Pour une Promise prise isolément, ces changements d'état n'ont pas une grande importance. Ce n'est plus le cas lorsqu'il s'agit de chaîner, de composer plusieurs Promises entre elles.
La méthode then() renvoyant elle-même une Promise, il est possible d'enchaîner les appels à cette méthode.
Dans le cadre de notre exemple, on aurait pu écrire le code suivant :
Code typescript : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | getDataPromise() .then( data => { // resolve() console.log("Process 1:", data); return getDataPromise(); }, error => { // reject() console.log(error); }) .then( data => { // resolve() console.log("Process 2:", data); return getDataPromise(); }, error => { // reject() console.log(error); }) .then( data => { // resolve() console.log("Process 3:", data); }, error => { // reject() console.log(error); }) ; |
Composition d'appels de la méthode then() à deux arguments (non recommandé)
On notera tout d'abord la ressemblance, voulue, avec les fonctions process1(), process2() et process3() du chapitre 2 sur les fonctions callback, sauf qu'ici il n'a pas été nécessaire de créer ces fonctions intermédiaires, limitant ainsi le recours au code spaghetti, tout en évitant la « pyramide de la mort ».
Code : | Sélectionner tout |
1 2 3 | Process 1: 0.9523288000061796 Process 2: 0.7071524761970143 Process 3: 0.31277126054349025 |
Exemple de sortie sans erreur avec la méthode then() à deux arguments
Ensuite, on remarquera le renvoi d'une nouvelle Promise (return getDataPromise()) dans les deux premiers appels à then(), dans la partie dédiée à la fonction resolve(). C'est le fait de retourner une nouvelle Promise qui permet à l'appel à la méthode then() suivante de produire un nouveau résultat, ici un nombre aléatoire. Sans cela, le second et le troisième appel à then() opéreraient sur une Promise déjà acquittée, issue du premier appel à then(), avec un résultat non défini (undefined).
C'est d'ailleurs ce qui se passe dans le code ci-dessus en cas d'erreur. La partie correspondant à la fonction reject() ne renvoie pas de nouvelle Promise.
Code : | Sélectionner tout |
1 2 3 4 5 6 7 | Process 1: 0.6955174514497151 Error: Error in retrieving data. at Timeout.setTimeoutCB [as _onTimeout] (promise.js:13:22) at ontimeout (timers.js:386:14) at tryOnTimeout (timers.js:250:5) at Timer.listOnTimeout (timers.js:214:5) Process 3: undefined |
Exemple de sortie avec erreur avec la méthode then() à deux arguments
On notera dans l'exemple de sortie ci-dessus la valeur undefined au niveau du Process 3.
Le code produit plus haut ne se comporte donc pas tout à fait comme son homologue avec les fonctions callback classiques. Ici, une erreur n'empêche pas la poursuite des appels.
Il est assez rare que cela soit un comportement souhaité, et comme vu dans le chapitre précédent, il est déconseillé de spécifier la fonction reject() lors de l'appel à la méthode then(). Il faut préférer l'utilisation d'une autre méthode, catch(), qui n'est qu'en fait une version simplifiée de then(), ne prenant qu'un seul argument, la fonction reject().
Code typescript : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | getDataPromise() .then( data => { // resolve() console.log("Process 1:", data); return getDataPromise(); }) .then( data => { // resolve() console.log("Process 2:", data); return getDataPromise(); }) .then( data => { // resolve() console.log("Process 3:", data); }) .catch(error => { // reject() console.log(error); }) ; |
Composition d'appels de la méthode then() à un seul argument, avec la méthode catch() (recommandé)
Code : | Sélectionner tout |
1 2 3 | Process 1: 0.29398199861957175 Process 2: 0.9661893214860926 Process 3: 0.9223972522278627 |
Exemple de sortie sans erreur avec la méthode catch()
Code : | Sélectionner tout |
1 2 3 4 5 6 | Process 1: 0.20758402318588276 Error: Error in retrieving data. at Timeout.setTimeoutCB [as _onTimeout] (promise.js:13:22) at ontimeout (timers.js:386:14) at tryOnTimeout (timers.js:250:5) at Timer.listOnTimeout (timers.js:214:5) |
Exemple de sortie avec erreur avec la méthode catch()
Lorsqu'une erreur se produit, celle-ci se propage de façon descendante jusqu'à la première méthode catch() trouvée. La méthode catch() peut apparaître tout au long de la chaîne, à condition que les catch() intermédiaires prennent soin de propager l'erreur tout au long de la chaîne à l'aide de l'instruction throw. Cela peut être utile pour gérer les erreurs dès qu'elles surviennent, dans une logique de travail en équipe où chaque développeur est responsable de la gestion des erreurs de son domaine fonctionnel, et aussi pour éviter d'avoir un gros switch dans le catch() final. Il y aurait beaucoup à dire sur la manière de gérer les erreurs avec les Promises, mais cela dépasserait probablement le cadre de cette introduction.
Il est possible de combiner les Promises autrement qu'avec then(). Présentons deux autres méthodes standards offertes dans la norme ES2015 : all() et race().
La méthode all() prend en paramètres une séquence de Promises et renvoie une Promise qui est honorée si et seulement si toutes les Promises passées en paramètres sont honorées également. La valeur finale étant un tableau des valeurs des Promises honorées. Dans le cas contraire, si au moins l'une des Promises passées en paramètres a été rompue, alors la Promise renvoyée par la méthode all() sera rompue également. En gros, c'est tout ou rien.
Code typescript : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 | Promise.all([getDataPromise(), getDataPromise(), getDataPromise()]) .then( data => { console.log("Success!", data); }) .catch(error => { console.log(error); }) ; |
Exemple d'utilisation de la méthode all()
Code : | Sélectionner tout |
Success! [ 0.5930896059730313, 0.4881301731930028, 0.338379519116232 ]
Exemple de sortie sans erreur de la méthode all()
La méthode race() quant à elle prend également en paramètres une séquence de Promises et renvoie une Promise qui est honorée si au moins une des Promises passées en paramètres a été honorée. La valeur prise en compte étant celle de la première Promise à avoir été honorée, d'où le nom « race » (course en français). Ce n'est que si toutes les Promises passées en paramètres ont été rompues que la Promise renvoyée par race() sera rompue.
Code typescript : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 | Promise.race([getDataPromise(), getDataPromise(), getDataPromise()]) .then( data => { console.log("Success!", data); }) .catch(error => { console.log(error); }) ; |
Exemple d'utilisation de la méthode race()
Code : | Sélectionner tout |
Success! 0.2673875038150235
Exemple de sortie sans erreur de la méthode race()
4. Conclusion
Terminons cet article sur quelques considérations de compatibilité. Si votre plateforme cible ne supporte pas les Promises (et plus généralement la norme ES2015), il est possible d'avoir recours à un polyfill. Il en existe de nombreux sur la toile, 100 % compatibles avec la norme ES2015. Il y a aussi le transpilateur Babel qui inclut un polyfill pour les Promises.
Pour compiler du code TypeScript en ES5 tout en utilisant les Promises, il convient d'utiliser un polyfill tiers comme indiqué précédemment, et d'inclure la bibliothèque es2015.promise dans l'option lib du fichier de projet tsconfig.json.
Comme on vient de le voir, les Promises sont une avancée très appréciable pour mieux structurer son code asynchrone, et ceci au prix d'une abstraction somme toute minime. La conversion à partir du callback pattern historique est relativement directe. Cependant, même si le code résultant est davantage linéaire, il reste encore assez éloigné à du code synchrone, impératif, en général plus facile à appréhender. Mais ça, c'est le rôle des instructions async/await initialement prévues pour la version ES2016, mais reportées pour cette année 2017, et qui pourront faire l'objet d'un article dédié.