Allow "awaiting" on arrays

Abstract

Just like we can make use of the await on async functions. I suggest what I think is really simple, but very useful as well. A new way to await over an array of promises.
A syntactic sugar for the old Promise.all()

The problem (AKA current implementation)

It's not uncommon to have something like this all across our code:

function getUser(id) {
  return userStore.getById(id);
}

async function loadUsers(ids) {
  const usersPromisesQueue = ids.map(async id => await getUser(id)); // usersPromisesQueue is now an array of promises that must be awaited
  const users = await Promises.all(usersPromisesQueue);

  return users;
}

Or you could say that I could simplify that as follow:

function loadUsers(ids) {
  return Promises.all(ids.map(getUser));
}

But I suggest to make arrays "thenables" by default allowing us to just await on them.

Because if you go with the first implementation, what is the better name for a usersPromisesQueue?

Proposal

Just like an async function is just a syntactic sugar for a Promise. What about making await over an array a syntactic sugar for the Promise.all(). The exact same behavior.

So that our code could look just like:

async function loadUsers(ids) {
  const users = await ids.map(async id => await getUser(id)); // notice the await directly executed over the result of the .map (an Array)
}

Or even better. What about:

function loadUsers(ids) {
  return ids.map(getUser);
}

async function loadContext(usersIds, ...moreStuff) {
  const users = await loadUsers(usersIds); // since the return of the loadUsers() function is an array. We could simply await on it.

  // ... more stuff here
}

And the Polyfill for this will be pretty easy.

For me, this makes total sense. Because it makes the code a lot more readable.

So, I would like you guys to leve some comments. And PLMK what do you think :blush:

1 Like

What if the array is just an array of objects that happen to have .then methods but don't follow the thenable protocol? Also, there's a valid use case for returning arrays of promises without flattening them - you might want to chain the inner promises separately for whatever reason (think: tasks and such).

Pretty sure this would also be web-incompatible to boot, but I have neither numbers nor access to the tools to verify that claim.

2 Likes

To make this work, you’d have to have Array.prototype.then defined; this would immediately destroy any website that used a promise for an array.

2 Likes

And what if it would be a different operator, lets name it for now dawait for "deep await" that would await not only for emidiate operand to be resolved but for any deep property of resolved operand to be resolved as well? So it would work for arrays of promises, objects of promises etc.?

1 Like

Well, the result should be the same that what happens with the Promise.all() method. This must be just a simplified way to do it.

This implementation does not force you to always flat your arrays of promises. You should be able to just return arrays of promises as always and manipulate them as usual.

Absolutely.
The whole idea around this, is to define and standardize the Array.prototype.then

Perhaps a little bit more complex if we want to make "thenable" any kind of iterator.

What I'm questioning with this is the necessity of Promise.all() by itself (for arrays of promises).
If Array.prototype.then is not defined yet, what stop us to define this behavior?

const array = [];
array.then(result => {
  console.log(result);
})

This ↑ has a lot more sense to me.

Not sure if I get it. Could you explain this to me?

Well, for one, Promise.all already returns a promise for an array. This means array can never ever be thenable, or every usage of Promise.all would break (it would also mean that β€œwhat Would Array.prototype.then produce a Promise for” be unanswerable).

1 Like

I generally like the idea of making it easier to work with an array of Promises, but I don't think this should be implemented for arrays in general.

The main point against Array.prototype.then for me is that returning a regular array, let's say with a few million elements from an async function (or resolving a promise with it), would then cause the whole array to be checked wether one of the elements contains a thenable. That's a major performance for a little benefit.

I'd rather propose something along the lines of:

  class AsyncArray extends Array {
     then(resolve, reject) {
        Promise.all(this).then(resolve, reject);
     }
  }

  // usage:
  await new AsyncArray(1, 2, 3).map(async n => n)

Update

I tried to make my own implementation (or polyfill) for this but I faced this interesting problem...

So it fail. But for a different reason.

Didn't we have await * for exactly this?

I wrote a polyfill for it before. But the solution is quite hacky (and might be buggy in nestted await or finally block).

DONT USE IT!!!! I have a better version on post #16 Allow "awaiting" on arrays

// DONT USE IT
Array.prototype.then = function x(resolve, reject) {
    Array.prototype.then = undefined
    const restore = f => value => {
        const result = typeof f === 'function' ? f(value) : value
        Array.prototype.then = x
        if (f === reject && typeof f === 'function') return Promise.reject(value)
        return result
    }
    return Promise.all(this).then(restore(resolve), restore(reject))
}

That seems like it would create the possibility that code could run in between the deletion and restoration of Array.prototype.then, causing it to break in unpredictable ways.

I would strongly caution everyone not to speculatively modify builtins, even in channels like this, since people often copy-paste code they find on the internet :-)

2 Likes

A await* expr mentioned by @jridgewell looks good

In another way, it's boring to write

await Promise.all(arr.map(async () { ... }))

If there is a syntax sugar for it, things will be nicer

for parallel await (const x of arr) {
    ...
}

I have a better polyfill now, it won't delete & restore the prototype @ljharb

Still, DONT USE IT IN YOUR PRODUCT

Array.prototype.then = async function (resolve, reject) {
    class NonThenableArray extends Array {}
    NonThenableArray.prototype.then = 0
    NonThenableArray.prototype[Symbol.species] = NonThenableArray
    const result = new NonThenableArray()
    // This won't make promises parallel
    for (const p of this) {
        try { result.push(await p) }
        catch (e) { return reject(e) }
    }
    resolve(result)
}

Seems like you'd want return Promise.allSettled(this) as the entire invocation instead of needing NonThenableArray? Either way tho, it's still dangerous to have examples of code that mutates builtins; all it takes is enough websites using code that does it and we'd never be able to use the name :-)

Yeah. It's just an example, and I won't encourage anyone to use it seriously.

But the ergonomics of handling multiple async tasks is bad today. Does it considerable things like await* array or for await* (const x of expr)?

await * imo doesn't convey anything about what it's doing; if it's using Promise.all semantics, then what about race, allSettled, or any?

I could see an await.all/await.race/await.allSettled/await.any, though (with the implication that any new combinators would get the same support).

2 Likes

await.all / await.race / await.allSettled / await.any

Cool. What about making it a proposal?

(Upper case in the meta property look a bit strange)

1 Like