add resolve and reject in the returned promise instance

Hi, sometimes we need to control the promise externally. If the returned promise includes resolve and reject, it would be very convenient!

const promise = new Promise((resolve, reject) => {
  // ...
})
promise.resolve(...)
promise.reject(...)
路路路

also, this is my current solution

let resolve, reject
const promise = new Promise((_resolve, _reject) => {
  resolve = _resolve
  reject = _reject
// ...
})

resolve(...)
// or
reject(...)

See: GitHub - tc39/proposal-promise-with-resolvers

1 Like

https://github.com/tc39/proposal-promise-with-resolvers

No, you almost never need to. The code that controls the promise should go inside the new Promise constructor, I've never seen a good reason not to do that. It's just as convenient, and properly handles exceptions. Offering a more "convenient" method for creating deferreds would make this error-prone pattern too common.

The Promise.withResolvers proposal is a bad idea that will hopefully get shot down by the TC.

Can you share a concrete example where you felt a need for it?

I would argue that with async functions, there is almost never a need to use the promise constructor unless:

  • You're adapting from a callback style API to a promise based one
  • you need to use a deferred pattern

The first one will hopefully cease to exist someday. The second one is still very much needed in some specialized code.

My point is, with a language provided helper to support the second case, there should be almost no need for explicitly calling a promise constructor. Most code should use async functions.

Can you share an example of that? I argue that even those specialised cases can be written in as few lines, with cleaner and less error-prone code, using the Promise constructor.

Here's one example I can think of off of the top of my head. Perhaps it's not the best, and I admit that the "today's version" isn't that bad either, but I'd still argue that the Promise.withResolvers() is cleaner. If it's cleaner enough to warrant adding the API is a whole different question.

// -- Today's version -- //

class TimeoutCanceledError extends Error {}

function cancelableTimeout(ms) {
  let cancelTimeout;
  const promise = new Promise((resolve, reject) => {
    const timeoutId = setTimeout(resolve, ms);
    cancelTimeout = () => {
      clearTimeout(timeoutId);
      reject(new TimeoutCanceledError('The timeout was canceled.'))
    }
  });

  return { promise, cancelTimeout };
}

// --- With Promise.withResolvers() -- //

class TimeoutCanceledError extends Error {}

function cancelableTimeout(ms) {
  const { promise, resolve, reject } = Promise.withResolvers();

  const timeoutId = setTimeout(resolve, ms);
  const cancelTimeout = () => {
    clearTimeout(timeoutId);
    reject(new TimeoutCanceledError('The timeout was canceled.'))
  }

  return { promise, cancelTimeout };
}
1 Like

Basically anywhere the production of a value is completely dissociated from its consumption. Some classic generic example are queues or mutexes.

We have about 100 cases of deferred usages through our codebase. Some could probably be re-written to avoid it, but some can't.

Arguably our deferreds also wrap the resolvers to solve some common memory leak issues that plague most implementations.

Not sure if covered in any of mhofman's examples, but I recall using it in a situation where a single handler was responsible for both the creation and settling of promises but through separate invocations - something along the lines of:

onMessage(payload) {
  switch(payload.type) {
    case "create": {
      deferred = Deferred() // aka Promise.withResolvers()
      break
    }
    case "complete": {
      deferred.resolve(payload.value)
      break
    }
    case "error": {
      deferred.reject(payload.reason)
      break
    }
  }
}

I disagree. The problem is setTimeout throwing: instead of rejecting the promise, you get an exception from the Promise.withResolvers version. (Admittedly this is rather unlikely here, basically only if ms is an invalid value, but it might be any programming mistake). That's why the code should go inside the promise executor.
True, the let declaration is a bit ugly, but this would be solved when using a cancel signal:

function cancelableTimeout(ms, signal) {
  return new Promise((resolve, reject) => {
    signal?.throwIfAborted();
    const timeoutId = setTimeout(done, ms);
    signal?.addEventListener("abort", () => {
      clearTimeout(timeoutId);
      reject(new TimeoutCanceledError('The timeout was canceled.'))
    });
  });
}

or to avoid memory leaks, it should be

function delay(ms, signal) {
  return new Promise((resolve, reject) => {
    function done() {
      resolve();
      signal?.removeEventListener("abort", stop);
    }
    function stop() {
      reject(this.reason);
      clearTimeout(timeoutId);
    }
    signal?.throwIfAborted();
    const timeoutId = setTimeout(done, ms);
    signal?.addEventListener("abort", stop, {once: true});
  });
}

I'd love if the TC would go forward with the cancellation protocol proposal so that we could extend the Promise constructor to simplify this function to just

function delay(ms, signal) {
  return new Promise(resolve => {
    const timeoutId = setTimeout(resolve, ms);
    return () => {
      clearTimeout(timeoutId);
    };
  }, signal);
//   ^^^^^^
}

Even there, I'd argue that it's not completely dissociated. The function that should return the promise still does two things: register the resolver function on the queue, and start something that will ultimately cause the queue to resolve the promise. Both of these should go inside the promise constructor, so that exceptions from them get caught.

Taking the queue example from your library, I'd rewrite that to

export const makeWithQueue = () => {
  const queue = [];
  function dequeue() {
    if (!queue.length) return;
    const [thunk, resolve] = queue[0];
    resolve(
      Promise.resolve()
      .then(thunk) // Run the thunk in a new turn.
      .finally(() => {
        queue.shift();
        dequeue();
      })
    );
  }

  return function withQueue(inner) {
    return function queueCall(...args) {
      const thunk = _ => inner(...args);
      return new Promise(resolve => {
        queue.push([thunk, resolve]);

        if (queue.length === 1) { // Start running immediately.
          dequeue();
        }
      });
    };
  };
};

Even while this may not be possible in 100% of all cases, it still should be the preferred pattern. Adding an API that makes it easier - and, admittedly, more obvious - to write the problematic version should be avoided.

That's curious, I've never seen that. Where is deferred declared, and which code is using the deferred.promise?

deferred was kept somewhere persistently, and there was likely some manager involved in handling those to deal with multiple instances. The create message somehow was able to get the promise back to the caller and was also responsible for passing the creation request to whatever other process would ultimately be coming back with the complete/error messages. I forget how exactly everything worked now; this was for a project I worked on years ago.

The problem is setTimeout throwing: instead of rejecting the promise, you get an exception from the Promise.withResolvers version. (Admittedly this is rather unlikely here, basically only if ms is an invalid value, but it might be any programming mistake). That's why the code should go inside the promise executor.

I have the opposite opinion: the desirable behavior in this case is that you get a synchronous exception, not a rejected promise. Failure to schedule a task in the first place is a different thing than the task encountering an error during its execution, and that failure should bubble to the thing that scheduled the task, not the thing that was eventually consuming its result. The fact that the Promise constructor conflates the two is part of the reason you sometimes want to avoid it.

This is an instance of a general rule: exceptions should be thrown at the point at which the error occurred.

The only reason to ever prefer the Promise constructor's behavior is when you're using it in a function which can only ever return a Promise, and nothing else, in which case it's sometimes convenient to treat it as if it were an async function. But that's not the case here, and it's not the case a lot of the time you're working with Promises explicitly.

2 Likes

What about import(Symbol()), which still throws asynchronously?

That's would fall under this exception

when you're using it in a function which can only ever return a Promise, and nothing else, in which case it's sometimes convenient to treat it as if it were an async function. But that's not the case here, and it's not the case a lot of the time you're working with Promises explicitly.

The import function is an async one, so all errors, including invalid parameters are given through the promise.

If, instead, import returned an object that contained the promise among other info, then it would conceptually be a sync function that prepared an asynchronous task as it ran, as opposed to an "async" function, and in this hypothetical case this invalid parameter error would be better as a sync error.

Thanks, I misread that part :+1: