Async JavaScript - 06 - Les Promises en profondeur

Les 3 états d'une Promise, le chainage, la propagation d'erreurs, le return oublie, et Promise.resolve(thenable).

Les Promises en profondeur

Ce que tu vas apprendre

  • Les 3 états d'une Promise (pending, fulfilled, rejected) et leurs transitions
  • Comment créer des Promises (constructeur, resolve, reject)
  • Le chainage et pourquoi .then retourne toujours une nouvelle Promise
  • La propagation d'erreurs et l'importance du placement de .catch
  • Le bug du return oublie dans .then
  • Promise.resolve(thenable) et le protocole thenable

Prerequisites

  • Avoir lu l'article sur l'ordre d'exécution
  • Avoir deja utilise des Promises en pratique (meme sans tout comprendre)

Les Promises sont arrivees dans ES2015 et elles ont change la facon dont on écrit du JavaScript asynchrone. Mais apres des annees a les utiliser, je suis convaincu que la majorite des développeurs ne comprennent pas vraiment comment elles fonctionnent. On les utilise. On copie des patterns. Mais quand ca casse, on tatonne.

Cet article va tout reprendre depuis les fondations.

Les 3 états

Une Promise est un objet qui represente une valeur qui n'existe peut-etre pas encore. Elle est toujours dans l'un de ces 3 états :

                 +-------------+
                 |   pending   |
                 |  (en cours) |
                 +------+------+
                        |
              +---------+---------+
              |                   |
              v                   v
      +-------+------+   +-------+--------+
      |  fulfilled   |   |   rejected     |
      | (valeur)     |   | (erreur)       |
      +--------------+   +----------------+

  * Une fois fulfilled ou rejected, l'etat ne change PLUS
  * On dit que la Promise est "settled" (reglee)

C'est un aller simple. Une Promise pending peut devenir fulfilled ou rejected, mais jamais l'inverse. Et une fois settlee, elle garde sa valeur (ou son erreur) pour toujours.

javascriptconst p = new Promise((resolve, reject) => {
  resolve(42);
  reject("erreur");    // ignore ! deja resolue
  resolve(100);        // ignore ! deja resolue
});

p.then(val => console.log(val)); // 42

Le premier appel a resolve ou reject gagne. Tous les suivants sont ignores silencieusement.

Creer des Promises

Le constructeur

javascriptconst p = new Promise((resolve, reject) => {
  // cette fonction est executee IMMEDIATEMENT (synchrone)
  const data = doSomething();
  if (data) {
    resolve(data);   // -> fulfilled avec data
  } else {
    reject(new Error("pas de data")); // -> rejected avec Error
  }
});

Le callback passe au constructeur (appelé "executor") est synchrone. C'est un piège classique -- on l'a vu dans l'article sur l'ordre d'exécution.

Promise.resolve et Promise.reject

Pour créer des Promises deja reglees :

javascriptconst fulfilled = Promise.resolve(42);
const rejected = Promise.reject(new Error("oops"));

// Equivalent a :
const fulfilled2 = new Promise(resolve => resolve(42));
const rejected2 = new Promise((_, reject) => reject(new Error("oops")));

Promise.resolve a une subtilite : si tu lui passes une Promise, elle la retourne telle quelle (pas de wrapping). Si tu lui passes un thenable, elle le "deballle". On y revient en fin d'article.

Le chainage : .then retourne une nouvelle Promise

C'est le concept le plus important de cet article. Chaque appel a .then() retourne une nouvelle Promise. Pas la meme. Une nouvelle.

javascriptconst p1 = Promise.resolve(1);
const p2 = p1.then(val => val + 1);
const p3 = p2.then(val => val * 3);

console.log(p1 === p2); // false
console.log(p2 === p3); // false

p3.then(val => console.log(val)); // 6

La valeur retournee par le callback de .then devient la valeur de résolution de la nouvelle Promise :

p1: fulfilled(1)
     |
     .then(val => val + 1)
     |
p2: fulfilled(2)
     |
     .then(val => val * 3)
     |
p3: fulfilled(6)

Et si le callback retourne une Promise, la chaîne attend que cette Promise se resolve :

javascriptPromise.resolve(1)
  .then(val => {
    return new Promise(resolve => {
      setTimeout(() => resolve(val + 1), 1000);
    });
  })
  .then(val => console.log(val)); // 2 (apres 1 seconde)

C'est ca qui rend le chainage si puissant. Tu peux mixer opérations synchrones et asynchrones dans la meme chaîne.

La propagation d'erreurs

Quand une erreur se produit (reject ou throw), elle "descend" la chaîne jusqu'au premier .catch :

javascriptPromise.resolve(1)
  .then(val => {
    throw new Error("boom");
  })
  .then(val => {
    console.log("jamais execute");
  })
  .then(val => {
    console.log("jamais execute non plus");
  })
  .catch(err => {
    console.log("attrape :", err.message); // "attrape : boom"
  });
.then -> ERREUR
  |
  .then  (saute)
  |
  .then  (saute)
  |
  .catch -> attrape l'erreur

Le placement du .catch compte

javascript// Pattern 1 : catch a la fin
fetchUser()
  .then(transformUser)
  .then(saveUser)
  .catch(handleError);
// handleError attrape les erreurs de fetchUser, transformUser ET saveUser

// Pattern 2 : catch au milieu
fetchUser()
  .catch(err => defaultUser)  // si fetchUser echoue, utilise un defaut
  .then(transformUser)         // s'execute avec le defaut
  .then(saveUser);             // peut encore echouer sans catch !

Le pattern 2 est une technique de recovery : le .catch retourne une valeur de remplacement et la chaîne continue. Mais attention -- si transformUser ou saveUser echoue, l'erreur n'est pas attrapee.

Mon conseil : mets toujours un .catch a la fin de chaque chaîne. Et si tu veux faire du recovery au milieu, ajoute quand meme un .catch final.

Le bug du return oublie

C'est le bug le plus courant avec les Promises. Et il est vicieux parce qu'il ne produit pas d'erreur -- juste un comportement inattendu.

javascript// BUG : return manquant
function getUser(id) {
  return fetch(`/api/users/${id}`)
    .then(response => {
      response.json();  // oubli du return !
    })
    .then(data => {
      console.log(data); // undefined !!
    });
}

Sans return, le callback de .then retourne undefined. La Promise suivante est donc resolved avec undefined, pas avec le résultat de response.json().

javascript// CORRECT
function getUser(id) {
  return fetch(`/api/users/${id}`)
    .then(response => {
      return response.json();  // return explicite
    })
    .then(data => {
      console.log(data); // les vraies donnees
    });
}

// OU avec arrow function implicite
function getUser(id) {
  return fetch(`/api/users/${id}`)
    .then(response => response.json())  // return implicite
    .then(data => console.log(data));
}

J'ai passe deux heures a debugger ce problème sur paltemps.fr une fois. Le data etait undefined et je cherchais le bug cote serveur. C'etait un return manquant cote client. Depuis, j'utilise des arrow functions a return implicite partout ou c'est possible.

Promise.resolve(thenable)

Un "thenable" est n'importe quel objet qui a une méthode .then. Les Promises sont des thenables, mais tous les thenables ne sont pas des Promises.

javascriptconst thenable = {
  then(resolve, reject) {
    resolve(42);
  }
};

const p = Promise.resolve(thenable);
p.then(val => console.log(val)); // 42

Promise.resolve détecté la méthode .then et créé une vraie Promise à partir du thenable. C'est comme ca que des librairies pre-ES2015 (comme Bluebird ou Q) restent compatibles avec les Promises natives.

Attention, le deballing est recursif :

javascriptconst nested = {
  then(resolve) {
    resolve({
      then(resolve2) {
        resolve2("deep");
      }
    });
  }
};

Promise.resolve(nested).then(val => console.log(val)); // "deep"

La résolution continue jusqu'a obtenir une valeur non-thenable. C'est défini dans la spec sous le nom "Promise Résolution Procedure".

Anti-patterns a éviter

Le constructeur inutile

javascript// MAUVAIS : Promise constructor anti-pattern
function getUser(id) {
  return new Promise((resolve, reject) => {
    fetch(`/api/users/${id}`)
      .then(res => res.json())
      .then(data => resolve(data))
      .catch(err => reject(err));
  });
}

// BIEN : fetch retourne deja une Promise
function getUser(id) {
  return fetch(`/api/users/${id}`)
    .then(res => res.json());
}

Si tu as deja une Promise, ne la wrap pas dans une autre. C'est du bruit pour rien.

.then(resolve, reject) vs .then().catch()

javascript// Les deux ne sont PAS equivalents
promise.then(onSuccess, onError);
// onError attrape SEULEMENT les erreurs de promise, pas celles de onSuccess

promise.then(onSuccess).catch(onError);
// onError attrape les erreurs de promise ET celles de onSuccess

Prefere .then().catch() dans la grande majorite des cas.

Résumé

  • Une Promise a 3 états : pending, fulfilled, rejected -- les transitions sont irreversibles
  • Le constructeur new Promise(executor) exécuté l'executor de manière synchrone
  • .then() retourne toujours une nouvelle Promise, résolue avec la valeur du callback
  • Les erreurs propagent dans la chaîne jusqu'au prochain .catch
  • Le return oublie dans .then est le bug #1 des Promises -- la chaîne continue avec undefined
  • Promise.resolve(thenable) deballe les thenables recursivement
  • Ne wrap pas une Promise dans new Promise -- c'est un anti-pattern

Precedent : Ordre d'exécution | Suivant : Async/Await

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.