Destructuring with `await`

I am not sure this has been discussed already but I find myself often writing repetitive code such as:

const thing = await namespace.thing;

and I wonder if we could have instead:

const {await thing} = namespace;

This alone is not super interesting but with namespaces carrying many things it makes destructuring super lovely:

const {
  a, b, c,
  await d,
  e,
  await f
} = my.alphabet;

Thoughts?

The proposal is pretty much this one with the detail that all awaits should be considered like Promise.all and not awaited one after the other (imho) as when dependencies on await is desired users can still split their intent explicitly while defaults should be sane and fast.

1 Like

One potential solution to this problem would be the await dictionary proposal, which proposes the addition of a new Promise.ownProperties() function. You give it an object, and it'll await all of the promises on that object, returning a new object with the promised values. Presumably, non-promise values would be passed through as-is, the same way they get treated in any other scenario.

So, we could solve your use-case using this function as follows:

const { a, b, c, d, e, f } = await Promise.ownProperties(my.alphabet);

One disadvantage is that we lose information about which properties were promises and which weren't. Maybe if we had this in combination with a pick syntax/API, we could get that too with two lines of code, albeit a bit more clunky.

// If we have pick syntax
const { a, b, c, e } = my.alphabet;
const { d, f } = await Promise.ownProperties(my.alphabet.{ d, f });

// If we have a pick function
const { a, b, c, e } = my.alphabet;
const { d, f } = await Promise.ownProperties(Object.pick(my.alphabet, ['d', 'f']));

I'm wondering if you could expound on your use cases a bit more. I personally haven't found myself having namespaces that had a mix of promises and normal values on there like this.

This might be good enough, although it's pretty verbose:

const { a, b, c, d, e, f } = await Promise.ownProperties(my.alphabet);

This one though:

const { d, f } = await Promise.ownProperties(Object.pick(my.alphabet, ['d', 'f']));

doesn't offer nothing more than:

const [ d, f ] = Promise.all([my.alphabet.d, my.alphabet.f]);

I mean ... we can find many utilities or workarounds but nothing would beat:

const { a, b, c, await d, e, await f } = my.alphabet;

One disadvantage is that we lose information about which properties were promises

On the other hand, my syntax proposal explicitly define what should be awaited and what shouldn't, living room for promises that are meant to be left as promised and not resolved.

I'm wondering if you could expound on your use cases a bit more

We have objects that carry both direct fields and methods/utilities but might need some asynchronous dance to be fully usable (a classic async init() or a ready: new Promise(...) to signal the object is fully bootstrapped):

const {
  type,
  utility,
  await ready
} = new Thing('a-type');

this is a pretty common scenario to me, specially for cross realms objects and we have many from the Workers' world ... I hope this makes sense.

1 Like

I'm a little confused about this.

const { a,  b } = obj;

... decomposes as

const a = obj.a;
const b = obj.b;

..., and this

const c = await obj.c;
...

... is something similar to

obj.c.then(retval => {
   const c = retval;
   ...
});

... assuming c is a property that returns a Promise. Then doesn't it stand to reason that this:

const {a, b, c} = await obj;

... should decompose to this:

const a = await obj.a;
const b = await obj.b;
const c = await obj.c;

...? The problem with putting await on the lhs is that it breaks the convention of use on await.

const { a, await b, c } = obj;

... looks like it should decompose into:

const a = obj.a;
const await b = obj.b;
const c = obj.c;

... which would be both a syntax error and completely insensible. Destructuring spreads the rhs term over each element of the lhs term. With the await keyword as part of the rhs term, it would be reasonable to assume it would be spread just the same. Awaiting an expression that doesn't return a promise just resolves immediately with the return value of that expression. So that's not an issue.

accordingly to your logic this decompress to:

obj.then(result => {
  const a = result.a;
  const b = result.b;
  const c = result.c;
});

not sure why you wrote that instead ... my proposal though, is that:

const { a, await b, c } = obj;

decompress (if this is the right term) into:

const { a, c } = obj;
const [ b ] = await Promise.all([ obj.b ]);

Add any other await in destructuring and the logic is the same:

const { a, await b, c, d, await e } = obj;

// transpile into
const { a, c, d } = obj;
const [ b, e ] = await Promise.all([ obj.b, obj.c ]);

I hope the proposal is more clear now.

1 Like

Generally, under normal circumstances, you'd be right. The rhs term is usually resolved into an object before the unrolling happens. However, if you do that with this scenario, you'll be awaiting the wrong thing, just like you suggested. To me at least, since await is intended to defer a single assignment until the value to be assigned is resolved, while destructuring is designed to set up multiple assignments from a single object, it makes more sense to give destructuring the higher priority.

Let's take it step by step. In order to process

const {a, b, c} = await obj;

you have to unroll it first. That means dealing with the destructuring first, which leads to:

const a = await obj.a;
const b = await obj.b;
const c = await obj.c;
...

From there, then you can finally process it, which would look the same as:

obj.a.then(result_a => {
   obj.b.then(result_b => {
      obj.c.then(result_c => {
         const a = result_a;
         const b = result_b;
         const c = result_c;
         ...
      });
   });
});

I hope that helps you understand my reasoning here. There's also another small issue that you tripped over with your approach.

What happens if the calculation of b has side effects that alter the calculation of c, or vice-versa? The approach you've taken would change the order of operations, and therefore the result. I know side effects are frowned on, but they exist none-the-less. Please give a little more consideration to the order of operations as well as both the consistency and clarity of intent and usage.

Since await is a rhs term, seeing it on the lhs will promote confusion in its usage.

it doesn't because that's not how current specs or JS works with await ... so I am not sure what you are after there.

What I've already described ... if there is a need to await destructuring, it's already possible, nothing changes ... if there is no need to await one property after the other (namespaces can have many things in it) it works ... if the namespace knows that c cant work until b is resolved, it can await behind the scene that b so everything works as well.

There is no use case left out in my proposal ... everything is possible by developers intent.

Nope, it changes nothing ... considering that also a getter can return a promise that await on other properties, it literally changes nothing to what's possible already.

I did ... everything is the same as it is these days for namespaces with promises except the destructuring can group AOT what to await for.

I was more confused by you thinking, or showing, that await obj result in awaiting every property of that obj ... to be honest ... my proposal is way simpler, not disruptive, and it addresses all use cases.

The thing about await is that it does in fact stop execution of the current function. If you await several consecutive assignments, subsequent assignments are not attempted until await returns, as if everything that follows await is in the .then() of the promise being awaited.

I understand that

const {a, b, c}  = await expression;

is already valid ES. That's part and parcel of the confusion I have with your request. What you seem to want to do, given the way await is used in the language, is more inline with the alternative description of how to resolve this kind of assignment. Trying to use await as a lhs keyword to affect the processing of a rhs expression when rhs is always processed first makes no sense. This is what I've been trying to point out to you.

The only way I can see what you're attempting as something reasonable is if it required the renaming style of destructuring to work. Something like this:

const {a, await b: b, c} = obj;

The reason I can see this as valid is because the renaming form of destructuring specifies a rhs property name separately from the lhs variable declaration. In this case, it's still relatively clear that the use of await only applies to the rhs term. Then it can be considered reasonable to resolve the above to:

const a = obj.a;
const b = await obj.b;
const c = obj.c;

However, as I said before, order of operations must be maintained as anything following await essentially appears in the .then() handler of the promise being awaited.

are you explaining me how await works?

this would work for me, but it also doesn't address the OP intent to avoid redundant and verbose operations ... the b example is fine, {await somethingMeaningful: somethingMeaningful} is overly awkward ... yet it's better than nothing, so if you think that works due lhs VS rhs gotchas, it's OK to me ... but destructuring is all about lhs being parsed after rhs, as you pointed out, so I am not sure I am following why, without property re-naming, it can't be possible ... but if you know the why, I trust you that's not possible with current parser.

edit on a second though, I don't understand why {a: _a, await b: _b, c: _c} is fine, but the parser can't implicitly do the same with {a, await b, c} and when : _b is written, overload that implicitly added await b: b ...

Maybe I wasn't clear enough....
What I've been referring to is the mental model of operation around the use of await. The key points are these:

  1. Its a rhs keyword only.
  2. It defers the operation of subsequent statements within the same function.

The parser can easily do the operation the way you've described it (barring the order of operations issues), but that's not the big problem. The big problem is in how it breaks the mental model of what's going on. If await can be used with a lhs term, then exactly what operation is being deferred? The point of forcing the renaming form of destructuring is about keeping it visually clear that what's being awaited is the rhs term, and has nothing to do with the lhs. Clarity is almost as important as functionality in programming (especially these days).

This model doesn't quite hold because:

const {a,b} = foo();

Doesn't de sugar to

const a = foo().a;
const b = foo().b;

it's

const tmp = foo();
const a = tmp.a;
const b = tmp.b;

The RHS is not distributed across the LHS.

2 Likes

You’re simply incorrect here; await is not always on the RHS. See for await of, and await using, and const { x = await y } = o.

1 Like

:thinking: Nice technicality. You almost got me with that one. Doesn't this decode to:

const x = o.x || await y;

? If it weren't for the default value assignment, you'd be right. However, local to its containing expression, it's still an RHS use of await. If you can show me a use of await that appears only on the lhs of its containing expression, then you'll convince me. :smile:

I mentioned two.

Took a moment to look at both of those. They're both of the general format await expression. It's really hair splitting, but even this doesn't count as a lhs await as in these expressions, there's no lhs to mention. They're both compound expressions with an inner assignment as the sub-expression. So, still no.

However, that does put up evidence for a prior art syntax that would make sense for what the OP seems to be trying to do, namely:

await const {a, b, c} = obj;

This would effectively do what seems to be intended (have promises of the target properties resolved before assignment) while staying consistent with notations that currently exist in ES.

Let me try to say what I'm thinking a different way. For me, the thing to the right of the await keyword must be an expression that resolves to either a Promise instance, a "then-able" object, or a first class value that can be wrapped and returned by a Promise. Arrangements that try to break this convention will invariably leave me a bit uncomfortable with the notation.

for await(const a of foo()) {...}

This works because what's being awaited is the expression const a of foo(), meaning foo() is re-evaluated and awaited on each loop, as opposed to

for (const a of await foo()) {...}

which would only evaluate and await foo() once, using the result for each iteration.

I just don't see the sense in something like:

const {await a} = obj;

because

const a = obj.(await a);

is invalid syntax, and

const await a = obj.a;

just doesn't make sense given that from the = on, everything is optional.

let await a;

seems like it would be valid syntax if await can be used as a lhs keyword. That's the problem I have.

@ljharb actually mentioned three. await using isn't in the language yet, but they're planning on adding it soon. This one's especially odd because the thing being awaited is the value being assigned to the variable, but when the awaiting actually happens is at the end of the block (at least, this is how I understand it).

For me, "await" just means that we're pausing the function until a promise (or thenable/whatever) finishes. I don't feel like every syntax we invent that pauses the function for a promise/thing must use "await" in the way you're describing. For me, the "const { await x } = y" syntax intuitively make sense - we're destructuring the object and awaiting for some of the destructured promises to resolve.

I'm just not sure I'm completely on board with it, not because it's unintuitive, but because I don't know if it's worth adding new syntax to solve this problem.

I'll try to give a concrete example, which is our daily code to deal with.

There is a definition per each interpreter, where an interpreter is provided via a module, which in turns can create different "sandboxes" ... they all do something like this:

// import the interpreter and returns a normalized namespace
// able to run code sync or async
function getInterpreter(url) {
  return {
    module: import(url),
    run: (sandbox, code) => sandbox.evalute(code),
    runAsync: (sandbox, code) => sandbox.evaluate(code, {async: true})
  };
}

// how things are done now ...
async function createSandbox(moduleURL) {
  const interpreter = getInterpreter(moduleURL);
  const {run, runAsync} = interpreter;
  const module = await interpreter.module;
  const sandbox = await module.createSandBox();
  return {
    run: run.bind(null, sandbox),
    runAsync: runAsync.bind(null, sandbox)
  };
}

// usage
const pyodide = await createSandbox('./pyodide.mjs');
pyodide.run(pythonCode);

There other cases where both loading the module / sandbox requires fetching config files to bootstrap and so on ... we're manually orchestrating Promise.all([...]) here and there to boost bootstrap and use parallelism as much as we can ... now, with my proposal:

async function createSandbox(moduleURL) {
  const {
    run, runAsync,
    await module: { createSandBox }
  } = await getInterpreter(moduleURL);
  const sandbox = await createSandBox();
  return {
    run: run.bind(null, sandbox),
    runAsync: runAsync.bind(null, sandbox)
  };
}

Now, when it comes to namespaces carrying lazy imports things become more interesting:

// lazy importMaps in JS (one that works in Workers too)
const urls = {
  'utils': './path/utils.js',
  'lib': 'https://esm.sh/lib',
  'poly': 'https://esm.sh/poly'
};

// handy proxy for lazy imports
const modules = new Proxy(Object.prototype, {
  get: (_, module) => import(urls[module])
});

// file X
const {
  await utils: { path, fs },
  await poly
} = modules;

// file Y
const { await lib } = modules;

So now we have a thin layer able to hot-swap loaded modules (change urls fields when/if needed) or lazy load all dependencies with ease, allowing modules to be destructured like an import could, or better, exactly like a dynamic const { stuff } = await import(url) would.

Is any of these use cases interesting? I find the latter one specially compelling for both prod and dev cases.

edit P.S. this latter example explicitly shows the intent of the proposal:

  • it is not desired to await all properties of an object ... only destructured properties with explicit await should be awaited
  • it is very desired to have all destructured awaited properties to be grouped as single Promise.all([...]) operation
  • it is still possible to attach or return, via the namespace, properties that are not promises, or promises themselves that should not be awaited during destructuring

Any deviation from any of these points will likely make the effort futile and the feature less interesting, imho.

edit2 the only "complexity" I see for this proposal is the case:

const {
          ┏━━━━━━━━━━━━━━━━━━━━━━━━┓
  await utils: { path, fs, await nested },
  await poly
} = modules;

The "obvious" solution I see is that utils should be group-awaited with poly and then its nested await should be grouped / awaited once utils is resolved ... in this case the Promise.all might not branch all levels equally, if difficult, but ergonomics will still be pretty awesome and easy on the eyes with way less boilerplate needed in general.

for history sake, I'd like to write down a more complete/concrete example for the lazy modules pattern that would work in Workers too:

// use a map or a literal
const urls = new Map([
  ['utils', './path/utils.js'],
  ['lib', 'https://esm.sh/lib'],
  ['poly', 'https://esm.sh/poly'],
]);

// allow loading relative or absolute modules too
const modules = new Proxy(Object.prototype, {
  get: (_, module) => import(
    urls.has(module) ?
      urls.get(module) :
      module
  )
});

// above changes allow this to work too
const {
  await utils: { path, fs },
  await ['./thing.js']: thing
} = modules;

I think if this syntax would be allowed/possible it would make destructuring in JS extremely lovely to work with. Thanks for at least considering this proposal.