Chaining Promises

To this point, promises may seem like little more than an incremental improvement over using some combination of a callback and the setTimeout() function, but there is much more to promises than meets the eye. More specifically, there are a number of ways to chain promises together to accomplish more complex asynchronous behavior.

Each call to then() or catch() actually creates and returns another promise. This second promise is resolved only once the first has been fulfilled or rejected. Consider this example:

  1. let p1 = new Promise(function(resolve, reject) {
  2. resolve(42);
  3. });
  4. p1.then(function(value) {
  5. console.log(value);
  6. }).then(function() {
  7. console.log("Finished");
  8. });

The code outputs:

  1. 42
  2. Finished

The call to p1.then() returns a second promise on which then() is called. The second then() fulfillment handler is only called after the first promise has been resolved. If you unchain this example, it looks like this:

  1. let p1 = new Promise(function(resolve, reject) {
  2. resolve(42);
  3. });
  4. let p2 = p1.then(function(value) {
  5. console.log(value);
  6. })
  7. p2.then(function() {
  8. console.log("Finished");
  9. });

In this unchained version of the code, the result of p1.then() is stored in p2, and then p2.then() is called to add the final fulfillment handler. As you might have guessed, the call to p2.then() also returns a promise. This example just doesn’t use that promise.

Catching Errors

Promise chaining allows you to catch errors that may occur in a fulfillment or rejection handler from a previous promise. For example:

  1. let p1 = new Promise(function(resolve, reject) {
  2. resolve(42);
  3. });
  4. p1.then(function(value) {
  5. throw new Error("Boom!");
  6. }).catch(function(error) {
  7. console.log(error.message); // "Boom!"
  8. });

In this code, the fulfillment handler for p1 throws an error. The chained call to the catch() method, which is on a second promise, is able to receive that error through its rejection handler. The same is true if a rejection handler throws an error:

  1. let p1 = new Promise(function(resolve, reject) {
  2. throw new Error("Explosion!");
  3. });
  4. p1.catch(function(error) {
  5. console.log(error.message); // "Explosion!"
  6. throw new Error("Boom!");
  7. }).catch(function(error) {
  8. console.log(error.message); // "Boom!"
  9. });

Here, the executor throws an error then triggers the p1 promise’s rejection handler. That handler then throws another error that is caught by the second promise’s rejection handler. The chained promise calls are aware of errors in other promises in the chain.

I> Always have a rejection handler at the end of a promise chain to ensure that you can properly handle any errors that may occur.

Returning Values in Promise Chains

Another important aspect of promise chains is the ability to pass data from one promise to the next. You’ve already seen that a value passed to the resolve() handler inside an executor is passed to the fulfillment handler for that promise. You can continue passing data along a chain by specifying a return value from the fulfillment handler. For example:

  1. let p1 = new Promise(function(resolve, reject) {
  2. resolve(42);
  3. });
  4. p1.then(function(value) {
  5. console.log(value); // "42"
  6. return value + 1;
  7. }).then(function(value) {
  8. console.log(value); // "43"
  9. });

The fulfillment handler for p1 returns value + 1 when executed. Since value is 42 (from the executor), the fulfillment handler returns 43. That value is then passed to the fulfillment handler of the second promise, which outputs it to the console.

You could do the same thing with the rejection handler. When a rejection handler is called, it may return a value. If it does, that value is used to fulfill the next promise in the chain, like this:

  1. let p1 = new Promise(function(resolve, reject) {
  2. reject(42);
  3. });
  4. p1.catch(function(value) {
  5. // first fulfillment handler
  6. console.log(value); // "42"
  7. return value + 1;
  8. }).then(function(value) {
  9. // second fulfillment handler
  10. console.log(value); // "43"
  11. });

Here, the executor calls reject() with 42. That value is passed into the rejection handler for the promise, where value + 1 is returned. Even though this return value is coming from a rejection handler, it is still used in the fulfillment handler of the next promise in the chain. The failure of one promise can allow recovery of the entire chain if necessary.

Returning Promises in Promise Chains

Returning primitive values from fulfillment and rejection handlers allows passing of data between promises, but what if you return an object? If the object is a promise, then there’s an extra step that’s taken to determine how to proceed. Consider the following example:

  1. let p1 = new Promise(function(resolve, reject) {
  2. resolve(42);
  3. });
  4. let p2 = new Promise(function(resolve, reject) {
  5. resolve(43);
  6. });
  7. p1.then(function(value) {
  8. // first fulfillment handler
  9. console.log(value); // 42
  10. return p2;
  11. }).then(function(value) {
  12. // second fulfillment handler
  13. console.log(value); // 43
  14. });

In this code, p1 schedules a job that resolves to 42. The fulfillment handler for p1 returns p2, a promise already in the resolved state. The second fulfillment handler is called because p2 has been fulfilled. If p2 were rejected, a rejection handler (if present) would be called instead of the second fulfillment handler.

The important thing to recognize about this pattern is that the second fulfillment handler is not added to p2, but rather to a third promise. The second fulfillment handler is therefore attached to that third promise, making the previous example equivalent to this:

  1. let p1 = new Promise(function(resolve, reject) {
  2. resolve(42);
  3. });
  4. let p2 = new Promise(function(resolve, reject) {
  5. resolve(43);
  6. });
  7. let p3 = p1.then(function(value) {
  8. // first fulfillment handler
  9. console.log(value); // 42
  10. return p2;
  11. });
  12. p3.then(function(value) {
  13. // second fulfillment handler
  14. console.log(value); // 43
  15. });

Here, it’s clear that the second fulfillment handler is attached to p3 rather than p2. This is a subtle but important distinction, as the second fulfillment handler will not be called if p2 is rejected. For instance:

  1. let p1 = new Promise(function(resolve, reject) {
  2. resolve(42);
  3. });
  4. let p2 = new Promise(function(resolve, reject) {
  5. reject(43);
  6. });
  7. p1.then(function(value) {
  8. // first fulfillment handler
  9. console.log(value); // 42
  10. return p2;
  11. }).then(function(value) {
  12. // second fulfillment handler
  13. console.log(value); // never called
  14. });

In this example, the second fulfillment handler is never called because p2 is rejected. You could, however, attach a rejection handler instead:

  1. let p1 = new Promise(function(resolve, reject) {
  2. resolve(42);
  3. });
  4. let p2 = new Promise(function(resolve, reject) {
  5. reject(43);
  6. });
  7. p1.then(function(value) {
  8. // first fulfillment handler
  9. console.log(value); // 42
  10. return p2;
  11. }).catch(function(value) {
  12. // rejection handler
  13. console.log(value); // 43
  14. });

Here, the rejection handler is called as a result of p2 being rejected. The rejected value 43 from p2 is passed into that rejection handler.

Returning thenables from fulfillment or rejection handlers doesn’t change when the promise executors are executed. The first defined promise will run its executor first, then the second promise executor will run, and so on. Returning thenables simply allows you to define additional responses to the promise results. You defer the execution of fulfillment handlers by creating a new promise within a fulfillment handler. For example:

  1. let p1 = new Promise(function(resolve, reject) {
  2. resolve(42);
  3. });
  4. p1.then(function(value) {
  5. console.log(value); // 42
  6. // create a new promise
  7. let p2 = new Promise(function(resolve, reject) {
  8. resolve(43);
  9. });
  10. return p2
  11. }).then(function(value) {
  12. console.log(value); // 43
  13. });

In this example, a new promise is created within the fulfillment handler for p1. That means the second fulfillment handler won’t execute until after p2 is fulfilled. This pattern is useful when you want to wait until a previous promise has been settled before triggering another promise.