tc39 / proposal-await-dictionary

A proposal to add Promise.ownProperties(), Promise.fromEntries() to ECMAScript
MIT License
86 stars 4 forks source link

overload `Promise.all` #13

Open hax opened 1 year ago

hax commented 1 year ago

Consider currently Promise.all({}) just throw, practically we could overload Promise.all:

const {a, b, c} = await Promise.all({a: getA(), b: getB(), c: getC()})

Pros: No naming issue (Promise.allOwnProperties() is too lengthy)

Cons: Theoretically, people could add Object.prototype[Symbol.iterator] and change the behavior.

ljharb commented 1 year ago

An additional con is that if they're passing a non-iterable right now it's almost certainly a bug, but this would mask it.

For example: Promise.all(getA(), getB(), getC()) is a likely bug where they forgot to wrap it in [ ], which currently throws - with this overload, it would just return an empty object because the a promise has no properties.

iow, this feels like a nonstarter to me.

ajvincent commented 1 year ago

Yeah, this is begging for trouble.

hax commented 1 year ago

MM also suggested the same issue, should we reopen?

ljharb commented 1 year ago

@erights' suggestion was a bit different: basically saying "if the first arg is not iterable and arguments.length > 1, throw", and then the fallback would be to accept the lone non-iterable object argument and return an object.

hax commented 1 year ago

Generally I like be strict on argument length, and it seems solve the misuse problem in this specific issue.

erights commented 1 year ago

Not just Promise.all but all four, overloaded exactly this way.

I favor that it operates on exactly the enumerable own properties, whether string-named or symbol-named, like ... and assign do.

Nice uniformity across the two overloads because, for a normal array, all the numeric indexed properties are also enumerable own.

TomasHubelbauer commented 1 year ago

Not just Promise.all but all four, overloaded exactly this way.

This is definitely the way to go when it comes to designing a nice API that is going to be a joy to use.

We already have a bunch of ugly names in JavaScript, and we even have a precedent for trying to fix them, like Object.prototype.hasOwnProperty being replaced by Object.hasOwn now. We don't need more ugly names.

The method should always take a single argument so that when someone forgets [ and] they get a runtime error and realize the mistake.

Akxe commented 1 year ago

An additional con is that if they're passing a non-iterable right now it's almost certainly a bug, but this would mask it.

For example: Promise.all(getA(), getB(), getC()) is a likely bug where they forgot to wrap it in [ ], which currently throws - with this overload, it would just return an empty object because the a promise has no properties.

iow, this feels like a nonstarter to me.

Pretty much every developer now a day uses some IDE. Given that the IDE will complain that the function got too many arguments.

I would say that in the modern day, this complaint is no longer valid.

ljharb commented 1 year ago

I don't use an IDE, and neither does every developer. The complaint will forever be valid.

Akxe commented 1 year ago

I don't use an IDE, and neither does every developer.

That's why there is Pretty much every and not every... What do you use, then? (I am curious)

The complaint will forever be valid.

Both complaints will be forever valid. The point of this discussion is to determine if the benefit of putting an object to this function would ease more people than it would hurt.

Pros:

Cons:

ljharb commented 1 year ago

I don't think a separate name is confusing to anyone; arguably, IDE autocomplete means it would be less confusing - that said, if we decide on a way to safely overload the existing mechanism, that's nice.

acutmore commented 1 year ago

Personally I very much like the idea of overloading the existing methods.

As mentioned there is the risk of Symbol.iterator being added or removed from an object which is also being passed to the overloaded Promise.all and this changing the outcome at a distance. I think this risk level is low because:

One hypothetical way to further reduce the risk would be to only accept iterables or objects with a prototype set to null. However I think the complexity, boilerplate, and restrictiveness of adding this limitation is not justified for the estimated level of risk.

It would be great to get as much feedback from developers with a diverse range of backgrounds on how their would find the overload vs separate dedicated APIs.

Akxe commented 1 year ago

Technically you could create an object with an iterator like so:

const obj = {
  [Symbol.iterator]: function* () {
    yield 1;
    yield 2;
    yield 3;
  },
};
await Promise.all(obj) // [1, 2, 3]

If a user does this, it is done by design... The risk, therefore, arising from this is minimal. However, this (or any similar) example should be documented in the proposal.

May I suggest a poll? (vote for both if you please) 🚀: overload 🎉: separate dedicated APIs

ajvincent commented 1 year ago

After a few weeks, my passion for opposing this has cooled a bit. I care more about a workable solution than how that solution is implemented.

That said, I'm thinking also about type definitions from TypeScript, which gives me another concern. The current type definitions in TypeScript for Promise.all are:

// from es2015.iterable.d.ts
all<T>(values: Iterable<T | PromiseLike<T>>): Promise<Awaited<T>[]>;

// from es2015.promise.d.ts
all<T extends readonly unknown[] | []>(values: T): Promise<{ -readonly [P in keyof T]: Awaited<T[P]> }>;

https://github.com/microsoft/TypeScript/blob/main/src/lib/es2015.iterable.d.ts https://github.com/microsoft/TypeScript/blob/main/src/lib/es2015.promise.d.ts

What would the types look like when overloaded?

bakkot commented 1 year ago

@ajvincent Presumably an extra overload along the lines of

all<T extends { [key: string]: unknown }>(obj: T): Promise<{ [P in keyof T]: Awaited<T[P]> }>

?

Seems to work fine in the playground.

Akxe commented 1 year ago

Since the array has -readonly I would assume this one would too. But yeah this overload is simple and correct.

ctcpip commented 1 year ago

if overloading, we would be overloading all of the Promise concurrency methods, right?

ljharb commented 1 year ago

Both Promise.all and Promise.allSettled, I'd expect.

ctcpip commented 1 year ago

ah right, this wouldn't make much sense for any and race

acutmore commented 1 year ago

There could be an overload for them...

const result = await Promise.race({
  a: new Promise(() => {}), // never resolves - always loses the race
  b: Promise.resolve(42),
}); 

result; // { b: 42 }
"a" in result; // false
"b" in result; // true

However I agree that I don't think we should pursue this design because Promise.race does not currently return an array container type so this overload would make a more significant change to the return type by adding a container that isn't there when an iterable is passed as an argument.

Akxe commented 1 year ago

Promise.all and Promise.allSttled should return the container as resolved from the Symbol.Iterator or enumerable keys of given object (in this order).

Promise.race and Promise.any should accept iterable and object but return just the result of the promise.

We must be consistent with the current API.

acutmore commented 1 year ago

We must be consistent with the current API.

The consistency here is that the overload is available when the method returns a container.

To pass an object to Promise.{race, any} could still be done as Promise.{race, any}(Object.values(obj)).

Akxe commented 1 year ago

To pass an object to Promise.{race, any} could still be done as Promise.race(Object.values(obj)).

True the benefit added by overload of Promise.{race, any} is minimal. Let's not mess with more than is necessary.