Course outline · 0% complete

0/27 lessons0%

Course overview →

Chaining: then returns a new promise

lesson 5-2 · ~11 min · 12/27

The chain rule

.then does not just attach a callback. It returns a brand-new promise whose value is whatever your callback returns.

That is what makes chains flat instead of nested, and it comes down to two cases.

  • Return a plain value, and the next .then receives it.
  • Return a promise, and the chain waits for it, then passes its resolved value along.

That second rule is the important one. Async steps line up one after another with no pyramid, because a returned promise is absorbed rather than passed along as an object.

Chaining is the feature that made promises win. Real work is a sequence, such as fetch the user, then fetch their orders, then render, where each step needs the previous step's result.

Without the chain rule you would be right back to nesting callbacks inside callbacks, one indent per step.

The absorbing behavior is called assimilation, and it works on anything with a .then method rather than only on real promises. That is why promise libraries from before 2015 interoperate with native ones.

Passing values down a chain

Each callback's return value feeds the next link.

Promise.resolve(2)
  .then((n) => {
    console.log("got " + n);
    return n * 2;
  })
  .then((n) => {
    console.log("doubled to " + n);
    return n + 5;
  })
  .then((n) => {
    console.log("final: " + n);
  });

Output

got 2
doubled to 4
final: 9

Watch 2 become 4, then 9, with each step reading the previous step's return value.

Nothing here is actually asynchronous, and the chain still defers. Every callback runs as a microtask, so a console.log after this whole expression would print before got 2.

Promise.resolve(2) is the compact way to start a chain from a value you already have, and it saves writing an executor that resolves instantly.

The last callback returns nothing, so the chain's final promise fulfills with undefined. That is harmless here because nothing is attached after it.

Written with nested callbacks this would be three levels of indentation. Written as a chain it is a flat list of steps, which is the readability argument in one comparison.

Returning a promise from inside then

The chain waits for each step before starting the next.

function delay(ms, value) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(value), ms);
  });
}

delay(20, "one")
  .then((v) => {
    console.log(v);
    return delay(20, "two");
  })
  .then((v) => {
    console.log(v);
    return "three";
  })
  .then((v) => console.log(v));

Output

one
two
three

The middle callback returns a promise, using the delay helper from lesson 5-1, and the chain absorbs it.

The second .then receives "two" rather than a promise object, which is the assimilation rule doing its job. Without it, v would be a pending promise and printing it would show Promise { <pending> }.

The whole thing takes about 40 ms, since the two delays run one after the other rather than together.

That sequencing is what you want when step two needs step one's result, and it is waste when the steps are independent. Lesson 6-2 covers running them at once with Promise.all.

The third callback returns a plain string, so the two return styles mix freely in one chain. Each link only cares whether it got a promise or a value.

p.then(fn) evaluates to a new promise that settles with whatever fn returns, waiting first if fn returns a promise.

Every .then creates a fresh promise. If fn returns a value, the new promise fulfills with it, and if fn returns a promise, the new one waits and adopts its result.

p itself is untouched, because promises never change after settling, which is the guarantee from lesson 5-1.

That immutability is why the same promise can feed two independent chains. Calling p.then(a) and p.then(b) gives two separate result promises, both reading the same p.

Branching like that is different from chaining, and mixing them up causes real confusion. p.then(a).then(b) sequences b after a, while two separate .then calls on p run a and b independently.

A throw inside fn settles the new promise as rejected rather than crashing, which is what makes the error handling in the next lesson possible.

The next .then receives undefined.

The new promise settles with the callback's return value, and a function with no return statement returns undefined.

Computing n * 2 without returning it discards the result. The multiplication happens and the answer goes nowhere.

The forgotten return is one of the most common promise mistakes flagged in real code reviews, and the reason it survives review is that nothing breaks loudly. The chain keeps going, just with the wrong value.

Arrow functions make it easy to get right and easy to get wrong. (n) => n * 2 returns implicitly, and adding braces as (n) => { n * 2 } silently stops returning.

The same bug in an async chain is worse, because a missing return on a promise-returning call also breaks the waiting. The chain moves on immediately, and any error from that unreturned promise becomes an unhandled rejection.

A two-step chain

Two transforming steps and one printing step.

Promise.resolve("ada")
  .then((name) => {
    return name.toUpperCase();
  })
  .then((name) => {
    return "Hello, " + name + "!";
  })
  .then((message) => console.log(message));

Output

Hello, ADA!

Each callback returns a value, which is what lets the next one receive something useful.

The return value does not have to be a promise. A plain string like name.toUpperCase() is wrapped in a resolved promise automatically, which is what lets these steps read like ordinary function calls.

The last .then returns nothing, and that is fine, because nothing is chained after it.

Splitting this into two steps rather than one is a style choice, and it pays off when the steps are real work rather than string manipulation. Each link is separately testable and separately replaceable.

Swapping either middle callback for an async one changes nothing structurally. Returning delay(20, name.toUpperCase()) in place of the plain string produces the same output, one tick later.