Promise returned by async functions

Posted on 2025-05-24 in Programmation

Recently at work, I encountered a problem with a function that should have returned a custom promise subclass. The goal of this subclass is to be able to call a cancel method to cancel some pending action done within the promise and then reject it. It looks like this:

class CancellablePromise extends Promise {
    constructor(executor) {
        super(executor)
    }

    cancel() {
        console.log("Canceling");
        // Actual operation
    }
}

Unlike what I expected, I didn’t get a CancelablePromise but a standard Promise object. I initially thought it came from the chain: I didn’t stored the return promise directly but the output of a .catch method like this:

const myPromise = buildCancellable().catch(() => console.error("Oops"));

After running some tests, it turns out that chaining from a custom promise class will return an instance of the custom promise class. So the culprit wasn’t there. I continued debugging and acquired the certainty that buildCancellable didn’t return the custom class at all in the first place. I’m also certain it used to.

It turns out the buildCancellable function was made async a few months ago. Instead of being defined like this:

const buildCancellable = () => new CancellablePromise(() => { ... });

It’s now defined like this:

const buildCancellable = async () => {
    // Stuff using await
    return new CancellablePromise(() => { ... })
};

It turns out that returned values of async function are always wrapped in a proper Promise even if they are themselves already a Promise. It has a very little impact on most usage since nested promise are automatically unwrapped, so we have this behavior:

const innerPromise = new Promise(resolve => {
  setTimeout(() => {
    console.log("Resolving inner promise");
    resolve("Inner value");
  }, 2_000);
});

const outerPromise = new Promise(resolve => {
  setTimeout(() => {
    console.log("resolving outer promise");
    resolve(innerPromise);
  }, 1_000)
});

const value = await outerPromise;

// Will log "Inner value" as expected.
console.log("The value:", value);

But it made the cancel method non reachable since the actual CancellablePromise cannot be reached now. The only solution is to remove the async keywords and remove await from the body.

I hope you found this reminder useful. I did have an of course moment once I figured it out, but was a bit lost at the start. I really expected to get the proper object back (and I still think it’s reasonable to expect Promise to not be wrapped again) since it’s already a Promise. I guess it’s best for consistency to have JS behave this way. And it’s transparent in almost all cases anyway.