DEV Community

Cover image for PromiseExtra.sequence
Amin
Amin

Posted on

PromiseExtra.sequence

Promises are very powerful at easing the manipulation of asynchronous contexts. And the language has several helpers to help us with that like Promise.all which takes an array of promises and return an array containing all of the resolved values from the promises provided.

There is one use-case where it would be great to have such helpers is when we want to work with a sequence of promises.

let name = "";
let age = "0";

question({message: "What is your name? "}).then(newName => {
  name = newName;
  return question({message: `Hi ${name}, what is your age? `});
}).then(newAge => {
  age = newAge;
  return question({message: `${name}, are you sure you are ${age}? `});
}).then(confirmation => {
  if (confirmation !== "yes") {
    console.log("Alright, I won't do anything for now.");
  } else {
    console.log(`Thank you ${name} for answering. I am processing your request...`);
  }
});
Enter fullscreen mode Exit fullscreen mode

If you don't see any problem with that source-code, then this article won't be of any interest for you but if you saw that we were using some global variables within our promises and that you would like to know if there is a solution to prevent manipulating such global variables then I'll show you my attempt at solving this issue.

You may want to know how the question function work. We will start by building our own question function using the Node.js platform and the JavaScript language and then we will quickly go back to this example and try to find a solution to this global variable issue.

Our own question

Asking questions on Node.js is a problem that has already been solved using the readline built-in module. We can even use the readline/promises submodule to use our beloved Promise object.

import {createInterface} from "readline/promises";
import {stdin as input, stdout as output} from "process";

const readlineInterface = createInterface({
  input,
  output
});

readlineInterface.question("How are you? ").then(mood => {
  console.log(`You are ${mood}.`);
}).catch(({message}) => {
  console.error(message);
}).finally(() => {
  readlineInterface.close();
});
Enter fullscreen mode Exit fullscreen mode

To put that in words:

  • We imported the createInterface from the readline/promises builtin module
  • We also imported input & output to use the console input and output
  • We created our interface
  • We then call the question function which will output the question and wait for the input
  • We catch the input in the resolved promise returned by question
  • We also catch any errors
  • We released the locking of the input

So yeah, this can quickly be tedious to write if we wanted to ask several things to our user. This is a good candidate for a function.

import {createInterface} from "readline/promises";
import {stdin as input, stdout as output} from "process";

const createQuestionFactory = ({createInterface, input, output}) => {
  const question = ({message}) => {
    const readlineInterface = createInterface({
      input,
      output
    });

    return readlineInterface.question(message).finally(() => {
      readlineInterface.close();
    });
  };

  return question;
};

const question = createQuestionFactory({
  createInterface,
  input,
  output
});

question({message: "How are you? "}).then(mood => {
  console.log(`You are ${mood}.`);
});
Enter fullscreen mode Exit fullscreen mode

If we run this code, we should get something like that.

How are you? fine
You are fine.
Enter fullscreen mode Exit fullscreen mode

That's my take at creating something reusable, but I'm pretty sure there are tons of ways to solve this issue, with plenty of optimizations but I don't want to spend too much time here.

The important thing is that we have a function that allows us to ask a question and returns a promise resolved with the answer. The implementation details are of little to no interest for this article.

Hitting the problem

Promises are again really great at managing asynchronous contexts within our scripts. But when it comes to managing multiple states associated to a business need, it becomes clear that we need to use the good old tools like variables to store data associated with a sequence of promises.

let name = "";
let age = "0";

question({message: "What is your name? "}).then(newName => {
  name = newName;
  return question({message: `Hi ${name}, what is your age? `});
}).then(newAge => {
  age = newAge;
  return question({message: `${name}, are you sure you are ${age}? `});
}).then(confirmation => {
  if (confirmation !== "yes") {
    console.log("Alright, I won't do anything for now.");
  } else {
    console.log(`Thank you ${name} for answering. I am processing your request...`);
  }
});
Enter fullscreen mode Exit fullscreen mode

This is the exact same code we had in the introduction. What's really bothering here is that we are using global variables. Variables are great, but they come with some drawbacks like naming them, conflict between multiple global variables, possibility of having the state of our variable changed, especially when we are dealing with an asynchronous context which can update our variable anytime and it becomes very hard to manage once our script grows in size.

Ideally, we would want to have something looking like that.

PromiseExtra.sequence([
  () => question({message: "What is your name? "}),
  () => question({message: "What is your age? "}),
  () => question({message: "Are you sure about your age? "})
]).then(([name, age, confirmation]) => {
  if (confirmation !== "yes") {
    console.log("Alright, I won't do anything for now.");
  } else {
    console.log(`Thank you for answering. I am processing your request...`);
  }
});
Enter fullscreen mode Exit fullscreen mode

If we try to run this code, we should get this result.

What is your name? Amin
What is your age? 28
Are you sure about your age? yes
Thank you for answering. I am processing your request...
Enter fullscreen mode Exit fullscreen mode

First, let's explain what is happening:

  • We used PromiseExtra.sequence, this is a function that we will be building together that accept an array of functions that return a promise
  • Then, we get back our values, just like the Promise.all function
  • The difference between PromiseExtra.sequence and Promise.all is that the latter has already the promises executed, and they are executed at the same time whereas the first has the execution of the promises deferred in a function that is called by PromiseExtra.sequence

PromiseExtra.sequence

Let's build our method. Here is my proposal definition.

const PromiseExtra = {
  sequence: (promises) => {
    return promises.reduce((previousPromise, currentPromise) => {
      return previousPromise.then(previousState => {
        return currentPromise(previousState).then(newState => {
          return [
            ...previousState,
            newState
          ];
        });
      });
    }, Promise.resolve([]));
  }
};
Enter fullscreen mode Exit fullscreen mode

Let's brake this in as usual.
PromiseExtra is an object containing a method, since this is not a constructor function, we don't need or want one and we can call this method like a static method on a class.

It contains a method sequence. This method is responsible for getting the array of functions and reducing it. It will reduce all of the promises to a single array of resolved values.

I start with a resolved promise since an empty array as parameter should resolve to an empty array anyway.

Then, if you are familiar with the reducing of arrays, you should get the idea. I received the previous resolved promise, I then grab the value inside this promise and call the current function (which is the current iteration, for each function in our array of function) and since the promise is deferred until the function is called, we can call it right now, get its resolved value and return the new state which is the aggregation of the old state and the new one.

It is a sequence, because we still call each one of our functions in the given order, and the promise are called only when we resolve the previous one. This is why we talk about deferred promises here.

Also, one important thing to note is that each function gets called with the previous state. This is helpful if we want to customize the behavior of each function from the derived state of the previous resolved promises. This let's us have a code that looks like that.

PromiseExtra.sequence([
  () => question({message: "What is your name? "}),
  ([name]) => question({message: `Hi ${name}, what is your age? `}),
  ([name, age]) => question({message: `${name}, are you sure you are ${age} years old? `})
]).then(([name, age, confirmation]) => {
  if (confirmation !== "yes") {
    console.log("Alright, I won't do anything for now.");
  } else {
    console.log(`Thank you ${name} for answering. I am processing your request...`);
  }
});
Enter fullscreen mode Exit fullscreen mode

And the output result would be the following.

What is your name? Amin
Hi Amin, what is your age? 28
Amin, are you sure you are 28 years old? yes
Thank you Amin for answering. I am processing your request...
Enter fullscreen mode Exit fullscreen mode

Now we have an enhanced user experience thanks to the accumulation of states provided for free by the PromiseExtra.sequence.

And for the ones that are in love with async/await, we can of course use it as well with this static method.

const [name, age, confirmation] = await PromiseExtra.sequence([
  () => question({message: "What is your name? "}),
  ([name]) => question({message: `Hi ${name}, what is your age? `}),
  ([name, age]) => question({message: `${name}, are you sure you are ${age} years old? `})
]);

if (confirmation !== "yes") {
  console.log("Alright, I won't do anything for now.");
} else {
  console.log(`Thank you ${name} for answering. I am processing your request...`);
}
Enter fullscreen mode Exit fullscreen mode

Note: you can use top-level await in recent versions of the browser and the Node.js platform.

Conclusion

We have seen what was the problem about sequencing promises, and the need for a more functional approach, imitating the foot steps of the Promise.all static method by creating our own PromiseExtra.sequence static method.

This article is heavily inspired by this answer on StackOverflow. I didn't find any satisfying solutions until I got to this answer so thanks for this one (except for the accumulation part).

Since this is a very interesting question (from my perspective and I hope from yours too), I'm curious about your findings and if anyone has a better solution to give and why this one is better according to you.

Even if this is quite niche, I truly hope that this will someday be part of the actual ECMAScript standard . What are your thoughts about this? Should this be part of the standard or rather a third-party library? Let me know in the comment section!

Anyway I hope that you enjoyed this article as I did because I had so much fun playing with this. Take care and see you on the next article!

Discussion (3)

Collapse
lukeshiru profile image
Luke Shiru

You can also use recursion instead of Array.prototype.reduce, like this:

const promiseSequence = ([promise, ...promises], previousData = []) =>
    typeof promise === "function"
        ? promise(previousData).then(promiseOutput =>
                promiseSequence(promises, [...previousData, promiseOutput]),
          )
        : Promise.resolve(previousData);
Enter fullscreen mode Exit fullscreen mode

And if you want to only know the previous resolved value in the sequence, instead of having all resolved values in an array, it becomes even simpler:

const promiseSequence =
    ([promise, ...promises]) =>
    previousResolve =>
        typeof promise === "function"
            ? promise(previousResolve).then(promiseSequence(promises))
            : Promise.resolve(previousResolve);
Enter fullscreen mode Exit fullscreen mode

Hope that helps.
Cheers!

Collapse
aminnairi profile image
Amin Author • Edited on

Hi @lukeshiru and thank you for your kind comment!.

I knew someone was gonna post a solution using recursion. I really love your solution (since I'm a functional programming amateur) which is shorter and simpler to understand (at least from my point of view) than mine using Array.prototype.reduce.

Actually, I would have used recursion in JavaScript all the way if proper tail call (and/or tail call optimizations) was a thing, and not only on Safari but in any platform. Since it is part of the ES2015 standard, I hope that it is and that someday it becomes a thing (even though Google Chrome seemed not pleased about it and has shown signs that they won't support PTC & TCO anytime soon).

But I guess it is okay to use it since we won't hit the call stack size limit anytime soon with this niche use-case.

And since you offered some variations of this sequence method, it makes me believe that there are so much more static methods that could be added to the Promise object for things like filtering, mapping, reducing, sequencing, accumulating etc... This may be a good candidate for a library some day.

Anyway, thanks again for your interest in this article. I'll see you soon and have a wonderful new year's eve.

Collapse
lukeshiru profile image
Luke Shiru

Recursion support sucks indeed, still as you mentioned that doesn't mean you can't use it in cases like this. PTC and TCO are a "requirement" if you're planning on doing recursion for lots of cycles, but for simple stuff is ok to use recursion (even more so if it keeps the code readable and simple).

Array.prototype.reduce generally makes code harder to read for folks, so I generally try to avoid it even if I personally like it, and use it mainly for sums or similar.

Cheers!

PS: Man! Reading the state of PTC and TCO in 2021 almost 2022 is depressing........