loading...

Would it make sense to have "Symbol.promise" as standard JavaScript Symbol?

noseratio profile image Andrew Nosenko Updated on ・2 min read

Updated: I've tried to provide a better context for this discussion with my new post: "Thenable: how to make a JavaScript object await-friendly, and why it is useful".

Asking for opinions. Would it make sense to have a standard symbol for the object's default awaitable, e.g. Symbol.promise, by analog to Symbol.asyncIterator?

I sometimes use the following pattern, in a nutshell (codepen):

class AsyncOperation {
  #promise = null;

  constructor(ms) {
    this.#promise = new Promise(r => setTimeout(r, ms));
  }

  then(resolve, reject) { 
    return this.#promise.then(resolve, reject); }
}

async function main() {
  await new AsyncOperation(1000);
  console.log("completed!")
}
Enter fullscreen mode Exit fullscreen mode

This works, because we've made an instance AsyncOperation to be thenable.

If however we had Symbol.promise, it'd be less boilerplate code for AsyncOperation:

class AsyncOperation {
  #promise = null;

  constructor(ms) {
    this.#promise = new Promise(r => setTimeout(r, ms));
  }

  get [Symbol.promise]() { return this.#promise; }
}
Enter fullscreen mode Exit fullscreen mode

Wouldn't Symbol.promise be useful?

Here is a less contrived snippet, adapted from some real-life code of mine:

const subscription = createSubscription(
  cancellationToken, eventSource, "eventName"); 
try {
  await subscription;
}
finally {
  subscription.close();
}
Enter fullscreen mode Exit fullscreen mode

The relevant part of createSubscription code:

  // ...
  return Object.freeze({
    close: () => unsubscribe(),
    then: (resolve, reject) => promise.then(resolve, reject)
  });
Enter fullscreen mode Exit fullscreen mode

I'd like to be able to do:

  // ...
  return Object.freeze({
    close: () => unsubscribe(),
    get [Symbol.promise]() { return promise; }
  });
Enter fullscreen mode Exit fullscreen mode

Of course, I could as well just expose promise as a property getter (and do await subscription.promise), or as a method, similar to RxJS' toPromise().

Yet the same arguments could possibly be used for iterable objects, which nevertheless expose their iterators via [Symbol.iterator] or [Symbol.asyncIterator]. Not via something like array.iterator or array.getIterator().

IMO, it'd be convenient if await looked for [Symbol.promise] in the same way the for...of and for await...of loops look for iterators.

Given that we already can await any expression in JavaScript (not just a Promise or "thenable"), I think that would make sense.

Discussion

pic
Editor guide
Collapse
the_spyke profile image
Anton Alexandrenok

Probably, the common JavaScript way of calling an async operation is less OOP-ish:

async function asyncOperation() { ... }

await asyncOperation();
Enter fullscreen mode Exit fullscreen mode

I might image that you want an object to get a cancel method or a progress property, which isn't standard by itself. There's a Stage 1 proposal for Cancellation API, but who knows how it will look like.

Looking into Subscription code, if a promise is one-time only, why doesn't it close itself on finish? Then it will be just

await waitForSubscription(
  cancellationToken, eventSource, "eventName"); 
Enter fullscreen mode Exit fullscreen mode
Collapse
noseratio profile image
Andrew Nosenko Author

The close method on subscription might be useful for scenarios like this:

const subscription = createSubscription(
  cancellationToken, eventSource, "eventName"); 
try {
  await Promice.race([
    subscription[Symbol.promise], 
    anotherPromise
  ]);
}
finally {
  subscription.close();
}
Enter fullscreen mode Exit fullscreen mode

In which case, if anotherPromise wins the race, I want to synchronously stop subscription.

As to cancellation, I currently use Prex library, for its close resemblance with .NET cancellation framework. I mentioned that in the TC39 cancellation discussion thread. Indeed, it's hard to predict what the final standard will be and it may take years before it reaches stage 4, so I'm just using something that is available today.

Collapse
the_spyke profile image
Anton Alexandrenok

If your close is my cancel and cancelling an already settled Promise makes no harm:

const subscriptionData = getSubscriptionData(
  cancellationToken, eventSource, "eventName"); 

try {
  await Promise.race([
    subscriptionData, 
    anotherPromise
  ]);
} finally {
  cancellationToken.cancel();
}
Enter fullscreen mode Exit fullscreen mode

But with finalized Cancellation API it could be something completely different.

I also could give you another idea for fun:

class AsyncOp {
  #resolve = null;
  #reject = null;
  constructor() {
    return new Promise((res, rej) => {
      this.#resolve = res;
      this.#reject = rej;
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Now it's officially a Promise without needing a global Symbol ))

Thread Thread
noseratio profile image
Andrew Nosenko Author

If your close is my cancel and cancelling an already settled Promise makes no harm:

I like your ideas, but the thing is, in the lacks of the standard cancellation framework for JavaScript, we all use different libraries. So, what works for you might not work for me :)

The prior art behind the current TC39 cancellation proposal is the .NET cancellation model, and it's also what's behind Prex, the library I use. It has two separate but connected concepts: CancellationTokenSource (the producer side of the API) and CancellationToken (the consumer part of it). Cancellation can be initiated on the source only, and observed on the token.

That makes sense, because cancellation is external to an API. But this way, using cancellation for stopping subscriptions gets a bit bulky, because I now need to create a temporary, linked token source just for the scope of my subscription, only to be able to cancel it (here is the complete runkit):

async function testSubscription(token) {
    const tokenSource = new prex.CancellationTokenSource([token]);
    const s = createSubscription(tokenSource.token);
    const p = prex.delay(prex.CancellationToken.none, 500); 
    try {
        await Promise.race([s, p]);
    } 
    finally {
        tokenSource.cancel();
        tokenSource.close();
    }
}
Enter fullscreen mode Exit fullscreen mode

Personally, I'd rather stick to using the subscription.close pattern, which is less verbose and reduces the number of allocations:

async function testSubscription(token) {
    const s = createSubscription(token);
    const p = prex.delay(prex.CancellationToken.none, 500); 
    try {
        await Promise.race([s.promise, p]);
    } 
    finally {
        s.close();
    }
}
Enter fullscreen mode Exit fullscreen mode

If there was standard Symbol.promise, it'd be Promise.race([s[Symbol.promise], p]), which I still think isn't too bad.

Your mileage with this may vary, depending on the libraries you use.

I also could give you another idea for fun [class AsyncOp]:

Prex has Deferred class for that, and I use it a lot :) It's semantically close to .NET TaskCompletionSource.

Thanks for the discussion!

Collapse
ashsearle profile image
Ash

Are you aware of the binding proposal?

That might reduce your boilerplate a little:

class AsyncOperation {
  #promise = null;

  constructor(ms) {
    this.#promise = new Promise(r => setTimeout(r, ms));
  }

  get then() { return ::this.#promise.then; }
}
Enter fullscreen mode Exit fullscreen mode

And:

  return Object.freeze({
    close: () => unsubscribe(),
    get then() { return ::promise.then; }
  });
Enter fullscreen mode Exit fullscreen mode

If the proposal was accepted, would you still push for Symbol.promise ?

Collapse
noseratio profile image
Andrew Nosenko Author

I actually wasn't aware of the binding proposal, thanks for pointing it out! It'd be great if it makes to the final stage soon.
I still think Symbol.promise would make sense if await was aware of it, to save a few implicit allocations otherwise incurred by awaiting via obj.then. Also, having a direct access to the object's default promise might save me from doing something like this: new Promise((...a) => obj.then(...a)), when then isn't enough and I need a promise - e.g., for use with Promise.race().

It'd also help to if I need the promise itself

Collapse
ashsearle profile image
Ash

I don't know enough about the mechanics and overhead of await to comment on that.

In the Promise.race case, it seems to handle primitives and thenables directly, so there may not be any need to wrap as a 'real promise'.

Reading up on Promise.resolve - is there a reason you can't use Promise.resolve(thenable) rather than spreading args with your new Promise idiom?

I find the Promise part of the EcmaScript spec really hard to read, so refer to the comments against Promise.resolve on MDN:

If the value is a thenable (i.e. has a then method), the returned promise will "follow" that thenable, adopting its eventual state; otherwise, the returned promise will be fulfilled with the value

Thread Thread
noseratio profile image
Andrew Nosenko Author

It never occurred to me I could use a thenable with Promise.resolve and Promise.race etc. Today I've learnt something new, thanks to you! It really does work:

export {}

class Thenable {
  #promise;

  constructor(ms) {
    this.#promise = new Promise(r => setTimeout(r, ms)); 
  }

  then(...a) { return this.#promise.then(...a); }
}

await Promise.resolve(new Thenable(2000));

await Promise.race([new Thenable(2000)]);
Enter fullscreen mode Exit fullscreen mode

I don't know enough about the mechanics and overhead of await to comment on that.

I could recommend this read on V8.dev: Faster async functions and promises.