TC39: Promises, Promises

This week's meeting has a few different Promise-related spec changes.

Promise.try: async function as a Library

One of the goals of Promise APIs is that exceptions that happen while processing the request become Promise rejections, rather than bubbling up to the top level (e.g. window.onerror).

This is true regardless of whether the error happened right away, or later on as the processing continues asynchronously.

Domenic Denicola wrote up guidelines along these lines for using promises in web specifications. The guidelines are now an official finding of the W3C Technical Architecture Group:

In particular, promise-returning functions should never synchronously throw errors, since that would force duplicate error-handling logic on the consumer: once in a catch (e) { ... } block, and once in a .catch(e => { ... }) block. Even argument validation errors are not OK. Instead, all errors should be signaled by returning rejected promises.

To recap, it is a mistake for functions that produce promises to synchronously throw exceptions. Instead, they should reject the promise.

This introduces an unfortunate problem when writing promise-producing functions:

function fetchUsersFrom(baseURL) {
  let url = new URL("/users.json", baseURL);
  return fetch(url);
}

In this case, if new URL('/users.json', baseURL) throws an exception (because baseURL is an invalid URL), fetchUsersFrom will throw an exception, rather than producing a rejected promise.

fetchUsersFrom("https://facebook.com/");
fetchUsersFrom("google.com"); // exception, oops!

In ES2015, the solution to this problem is:

function fetchUsersFrom(baseURL) {
  return new Promise((resolve, reject) => {
    let url = new URL("/users.json", baseURL);
    resolve(fetch(url));
  });
}

Because the callback to the Promise constructor catches exceptions and turns them into a rejected promise, this pattern avoids leaking exceptions that happen during the initial processing.

In ES2017, async function provides a more intuitive solution to the same problem:

async function fetchUsersFrom(baseURL) {
  let url = new URL("/users.json", baseURL);
  return await fetch(url);
}

async functions convert all exceptions that happen in the body of the function (before or after the first await) into promise rejections, so if you're using an async function the problem takes care of itself.

The proposal for Promise.try attempts to address the fact that the "promise constructor" pattern is awkward and unwieldy, without insisting on the use of async function:

function fetchUsersFrom(baseURL) {
  return Promise.try(() => {
    let url = new URL("/users.json", baseURL);
    return fetch(url);
  });
}

The way to think about Promise.try is as an analogue to a try {} block inside an async function (much the way a Promise's .catch and .finally methods are analogous to catch {} and finally {} clauses in async functions).

This feature advanced to Stage 1, which means the committee considers it an important area to investigate. Committee members raised several concerns about the feature, which make it not yet clear that the feature will eventually be included in the standard (and therefore it didn't advance to Stage 2).

The primary concern was that while Promise.try is a better incantation than the promise constructor, it's still an incantation you have to remember, and async function is both ergonomic syntax for this pattern and addresses the problem that Promise.try is attempting to address.

In response, people asserted that you don't always want to make an async function in this contexts, and an "immediately invoked async function" is still quite an incantation:

let promise = (async () => {
  let url = new URL("/users.json", baseURL);
  return await fetch(url);
})();

I suggested that this scenario might be solved by introducing async do:

let promise = async do {
  let url = new URL("/users.json", baseURL);
  await fetch(url);
}

People correctly pointed out that async do depends on making progress on do expressions, which are currently idling in Stage 0.

As an aside: I would love to see do expressions advance, but this post is already too long. Another time!

The upshot of the discussion is that Promise.try advanced to Stage 1, and Jordan will return in a future meeting with more direct responses to the raised concerns when he's ready to try for Stage 2.