Yeah, because such a trivial thing as the order of sub-expression evaluation may, completely opaquely, cause a crash-level error. To add insult to injury, it happens exactly in conjunction with the one library facility supposed to provide concurrent evaluation of asynchronous expressions... and what's the raison d'etre of async if not concurrent evaluation?
Come to think of it -- and I am just realizing this as I type, hah -- perhaps if concurrent await was a first-class construct in the language, it could have been handled better?
Let's assume we extend await to optionally support a list of concurrent asynchronous expressions. Note that this is emphatically not the same as "accept a list of promises", because it associates semantic meaning with the context of "argument-of-await" as will be demonstrated. Let's say we have a special syntax, "await[ list-of-expressions ]" in addition to the plain "await expression". Now we say that an expression that is an argument-of-await is evaluated as follows:
- If it terminates normally, evaluating to a promise, that promise is its value
- If it terminates normally, evaluating to a non-promise value, its value is a promise resolved to the value it evaluated to
- If it terminates abruptly, its value is a promise rejected with the exception thrown, or with the exception synthesized by runtime if the nature of abrupt termination is different from a thrown exception (I'm not sure if that's actually possible but the abrupt termination language in the standard includes other alternatives like break/continue and I don't want to spend time digging into edge cases like Duff's device right now)
So we've just covered the use case I was in such a tiff about: synchronous exception from an asynchronous-valued expression will not create orphaned promises assuming it is evaluated in the context of argument-of-await because it will be converted to a promise at the boundary of that context -- either the closing bracket or a comma separating arguments. Of course we now effectively have two commas with different behavior, "sync comma" in normal expressions and argument lists and "async comma" at top level inside "await[ ... ]". But that feels like a reasonable price to pay for having a separate syntax for async expressions.
Now I think we can get extra mileage from the new syntax by considering what is the value returned by await. In case of the b/w compatible, single argument await, it would still be the value the promise has resolved to, which is only reasonable -- there are no obvious alternatives to the current behavior. In case of the new await[...], we are free to evaluate to something other than an array of promise values.
There still needs to be a way to [synchronously] get the values from outside the [resolved] await and to propagate exceptions, but it may well be done explicitly. Assuming that await[...] returns a library object with multiple available methods, we could have
const listOfValues = await[ foo(), bar() ].all();
or
const oneValue = await[ foo(), bar() ].race();
or a more elaborate API that can implement custom strategies such as race-to-the-first-but-preserve-the-rest or collect-all-not-failed or what have you. If cancellation "backchannel" was added to promises, it could also make use of that as optimization.
I guess I rambled enough... it does look like language improvements are not only possible but also feasible, although they aren't by any means obvious.