DEV Community

Cover image for Forever Functional: Waiting for some promises?
OpenReplay Tech Blog
OpenReplay Tech Blog

Posted on • Edited on

Forever Functional: Waiting for some promises?

by Federico Kereki

Let's start this article with a programming challenge I sometimes pose when interviewing potential developers. You're asked to write a service that will accept a city name and return the current temperature there. To do this, you may call three third-party weather APIs, and as soon as you get one answer, reply with it. How do you do this?

If you are aware of JavaScript's promise concurrency methods, the answer is easy: generate promises that call each weather API, and use Promise.any() to get the needed value from the first promise that is fulfilled.

A variation: what if I had asked instead to wait until all weather APIs had answered and then reply with the average of the three temperatures? Easy, too: you would have used Promise.all() instead.

Now, here comes the question: what if I had asked you to wait until two weather APIs had answered and then return the average of both temperatures? There's no method for this; we need a Promise.some() that will allow you to wait for some --not one, not all-- of a set of promises, and this is what we'll do in this article.

Planning our new method

Before we start coding our Promise.some() method, we have to answer two questions:

  • what should the parameters for this method be?
  • what should the method return?

Both Promise.any() and Promise.all() take an iterable of promises as their (only) parameter, so that should be our first parameter too. We'll need a second one to specify how many promises should be fulfilled, so we're looking at Promise.some(promisesArray, fulfilledCount); easy!

For the second question, let's do some analysis:

  • Promise.any() is fulfilled when one of the array's promises is fulfilled and is rejected when all those promises are rejected. When fulfilled, the returned value is whatever the fulfilled promised returned, and when rejected, it returns an AggregateError with all the rejection reasons.

  • Promise.all() is fulfilled when all the array's promises are fulfilled and is rejected if just one of those promises is rejected. When fulfilled, the returned value is an array with all the fulfilled values, and when rejected, it returns an error with the first rejection reason.

  • Both methods apply short-circuit evaluation: Promise.any() is fulfilled as soon as any promise is fulfilled, and Promise.all() is rejected as soon as any promise is rejected.

So, now we can define Promise.some(promises, count) will be fulfilled if count promises are fulfilled and rejected otherwise. When fulfilled, the returned value is an array with the values returned by the fulfilled promises, and when rejected, it returns an AggregateError with the reasons for the rejected promises.

We should also do short-circuit evaluation: Promise.some() should be fulfilled the moment enough promises are fulfilled, and it should be rejected if too many promises are rejected, making it impossible to get enough fulfilled promises.

With this definition, Promise.some(promises, promises.length) would be exactly equivalent to Promise.all(promises). On the other hand, Promise.some(promises, 1) would not be equal to Promise.any(promises) -- the difference being that a fulfilled .any() returns a single, but .some() returns an array of values; in this case, a single one.

Coding our new method

Now we know what we want, let's see how to implement one. We can already start by modifying the Promise.prototype object:

Promise.some = function (promises, count) {
  return promiseSome(promises, count);
};
Enter fullscreen mode Exit fullscreen mode

(What if you don't want to modify a global object, which is usually something you shouldn't do? In that case, directly use the promiseSome() function, and everything will work.)

Obviously, there's a small detail -- what is the promiseSome() function? We will have to build a new promise out of all the promises in the array. We'll also have to keep track of the individual promises as they resolve or reject. The new promise will resolve or reject only when enough of the individual promises have been resolved or rejected. This code will do:

function promiseSome(promises, count) {                        // (1)
  return new Promise((resolve, reject) => {                    // (2)
    const resolved = [];                                       // (3)
    const rejected = [];                                       // (4)

    promises.forEach((promise, index) => {                     // (5)
      promise                                    
        .then((val) => {                                       
          resolved.push(val);                                  // (6)
          if (resolved.length === count) {                     
            resolve(resolved);                                 // (7) 
          }
        })
        .catch((err) => {
          rejected.push(err);                                  // (8)
          if (rejected.length > promises.length - count) {     
            reject(new AggregateError(rejected));              // (9)
          }
        });
    });
  });
Enter fullscreen mode Exit fullscreen mode

Let's analyze how this works. The promises array has all the individual input promises of which we want count to resolve (1). We create a new promise (2) that will resolve or reject depending on what the input promises do. We will use two arrays, resolved (3) and rejected (4), for the results of promises as they either resolve or reject. For each of those promises (5), if it resolves, we'll store the result in the resolved array (6), and if enough promises got resolved, we'll resolve our new promise with the resolved array (7).

If an individual promise rejects, we'll do a similar job, but we'll store the reason for rejection in the rejected array (8). If too many promises have rejected (making it impossible to achieve count resolved ones), we'll reject our new promise with an AggregateError with the array of reasons (9). We are done!

Session Replay for Developers

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay β€” an open-source session replay suite for developers. It can be self-hosted in minutes, giving you complete control over your customer data

OpenReplay
Happy debugging! Try using OpenReplay today.

Testing our new method

To test this, we'll require some fake promises.

const success = (time, value) =>
  new Promise((resolve) => setTimeout(resolve, time, value));

const failure = (time, reason) =>
  new Promise((_, reject) => setTimeout(reject, time, reason));
Enter fullscreen mode Exit fullscreen mode

Our success() function returns a promise that, after some time, will resolve to a given value. Similarly, the failure() function returns a promise that, after time, will reject with a reason. With these functions we can run some checks, which would be directly turned into Jest if we wanted to.

Promise.some([success(2000, 1), success(1500, 2), success(500, 3)], 2).then(
  (r) => console.log("Test #1: Should succeed with 2 and 3", r)
);

Promise.some([success(2100, 4), failure(400, 5), success(600, 6)], 1).then(
  (r) => console.log("Test #2: Should succeed with 6", r)
);

Promise.some([success(2200, 7), failure(300, 8), success(700, 9)], 3).catch(
  (r) => console.log("Test #3: Should reject with 8", r)
);

Promise.some([success(900, 10), failure(1800, 11), failure(800, 12)], 2).catch(
  (r) => console.log("Test #4: Should reject with 11 and 12", r)
);
Enter fullscreen mode Exit fullscreen mode

Running this produces the following:

Test #3: Should reject with 8 AggregateError {
  [errors]: [ 8 ]
}
Test #2: Should succeed with 6 [ 6 ]
Test #1: Should succeed with 2 and 3 [ 3, 2 ]
Test #4: Should reject with 11 and 12 AggregateError  {
  [errors]: [ 12, 11 ]
}

Enter fullscreen mode Exit fullscreen mode

Let's check.

  • Test #3 fails at time 300ms, when it's clear that we won't be able to resolve enough promises.
  • Test #2 succeeds at time 600ms, when a single promise is resolved, despite already having had a failure at time 400ms.
  • Test #1 is next; at time 1500ms two promises are resolved.
  • Test #4 is last, at time 1800ms, when we get a second rejection.

We could do more tests with more functions, but it seems we're on the right way, and our Promise.some() method is working the way we want.

Typing our method

If you're using TypeScript, you'll need to define some typing, which has some interesting details. First, we'll have to npm install aggregate-error so TypeScript will accept using AggregateError(), and we'll be able to add typing to promiseSome().

function promiseSome(promises: Promise<any>[], count: number): Promise<any[]> {
  return new Promise((resolve, reject) => {
    const resolved: any[] = [];
    const rejected: any[] = [];

    promises.forEach((promise, index) => {
        .
        .
        .
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Types are straightforward: we get an array of promises of any type and a number, and we produce a promise that will resolve to an array of promises. The resolved and rejected arrays may have any types, so that's not complex either. There is a problem, though, if you want to modify the Promise global object. The solution requires adding a definition to the PromiseConstructor interface as follows:

declare global {
  interface PromiseConstructor {
    some(promises: Promise<any>[], count: number): Promise<any[]>;
  }
}
Enter fullscreen mode Exit fullscreen mode

We are saying now that any promise will also have an additional some() method, and we can do the following.

Promise.some = function (promises: Promise<any>[], count: number) {
  return promiseSome(promises, count);
};
Enter fullscreen mode Exit fullscreen mode

If you want to run the tests again, you'll need typing for our fake promises, but that's not hard. We'll have two generic functions, depending on a type parameter T, as follows.

const success = <T>(time: number, value: T): Promise<T> =>
  new Promise((resolve) => setTimeout(resolve, time, value));

const failure = <T>(time: number, reason: T): Promise<T> =>
  new Promise((_, reject) => setTimeout(reject, time, reason));
Enter fullscreen mode Exit fullscreen mode

We're done!

Conclusion

In this article, we've seen a problem related to handling several promises, and we added a new method to promise objects to solve it, which we tested with a number of cases. For completeness, we also saw how to convert our code to TypeScript, so we can use it anywhere. A win all around!

A TIP FROM THE EDITOR: This series of articles has more on promises; check out Waiting With Promises
and Memoizing Promises.

newsletter

Top comments (0)