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

Andrew Nosenko @noseratio

About: Dad, self-employed, problem solver at heart, async all the way. Formerly a principal software engineer at Nuance Communications. Occasionally I tweet, blog and answer my own StackOverflow questions.

Location:
Sydney, Australia
Joined:
Sep 20, 2019

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

Publish Date: Oct 22 '20
2 8

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.

Comments 8 total

  • spyke
    spykeOct 22, 2020

    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
    • Andrew Nosenko
      Andrew NosenkoOct 22, 2020

      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.

      • spyke
        spykeOct 22, 2020

        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 ))

        • Andrew Nosenko
          Andrew NosenkoOct 22, 2020

          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!

  • Ash
    AshNov 6, 2020

    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 ?

    • Andrew Nosenko
      Andrew NosenkoNov 6, 2020

      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

      • Ash
        AshNov 6, 2020

        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

        • Andrew Nosenko
          Andrew NosenkoNov 6, 2020

          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.

Add comment