RSS feed iconGitHub logo

Awaiting problems in JavaScript

2022-10-247 minutes readJavaScript

As discussed in JavaScript's promised convenience, Promise.prototype.then combines the functionality of monadic chain and map operations. The callback can return either a regular value or a Promise and .then will await the Promise resolution in the chain. This blocks the possibility of actually returning a Promise from then. Some HackerNews comment suggested using async/await instead. Let's explore if it can be used as a workaround. In this article I explore some perplexing behavior of async functions regarding returning Promises and awaiting non-Promises.

Awaiting... promises

await let's us write code that looks like it is synchronous but is asynchronous under the hood.

function thenWorkflow() {
  return fetch(url)
    .then((response) => response.json())
    .then((data) => {
      console.log(data);
    });
}

async function asyncWorkflow() {
  const response = await fetch(url);
  const data = await response.json();
  console.log(data);
}

These 2 functions are equivalent. The Promises they return (did I say that async functions automatically return a Promise, you just don't see it?) will resolve with undefined, and will reject with an error from either fetch or response.json(), should there be any.

Returning the data is easy enough.

 async function asyncWorkflow() {
   const response = await fetch(url);
   const data = await response.json();
-  console.log(data);
+  return data;
 }

There, the Promise returned from asyncWorkflow will resolve with data instead of logging it to the console. data is a regular value, not a Promise, so this operation does not require any implicit logic. Plain and simple.

Returning Promises from async functions

We could refactor the code to avoid having an intermediate data variable that is returned immediately after being defined.

 async function asyncWorkflow() {
   const response = await fetch(url);
-  const data = await response.json();
+  return await response.json();
 }

Another convenience of async functions, this time, is that the returned Promise will be automatically awaited. Thus, we can remove the unnecessary await keyword.

 async function asyncWorkflow() {
   const response = await fetch(url);
-  return await response.json();
+  return response.json();
 }

The code still works the same way. Magic!

But wait, isn't it the same behavior as the lack of preciseness we found in Promise.prototype.then? Looks like it is. The behavior of async function's return differs depending on whether you return a Promise or a regular value. Returning a Promise awaits it, returning a regular value is a plain return. This is the same behavior as in Promise.prototype.then. This makes it impossible to return a Promise from an async function and get that Promise object.

In TypeScript's terms, the return type of an async function will never be Promise<Promise<unknown>>. In fact, I'm pretty sure such a value will never exist in JavaScript. Even Promise.resolve(Promise.resolve(1)) is just a Promise<number>, not Promise<Promise<number>>.

Awaiting... errors

Let's add some very basic error handling to our asyncWorkflow.

 async function asyncWorkflow() {
+  try {
     const response = await fetch(url);
     return response.json();
+  } catch (error) {
+    console.log("Network or deserialization error", error);
+  }
 }

We said that return await worked the same way as return before we added error handling. Does it work the same now?

 async function asyncWorkflow() {
   try {
     const response = await fetch(url);
-    return response.json();
+    return await response.json();
   } catch (error) {
     console.log("Network or serialization error", error);
   }
 }

It works differently! Not awaiting response.json() will make it so that the try clause won't catch deserialization errors. The only errors that will be caught will be the ones from fetch. When we await response.json() and return the result, errors from both operations can be caught.

This suggests that the Promise returned from an async function is somehow awaited outside of the function. It is not returned verbatim from the function, because additional properties attached onto the returned Promise are not preserved:

async function getAnnotatedPromise() {
  const promise = Promise.resolve("hello");
  promise.myValue = 123;
  return promise;
}

(async () => {
  const annotatedPromise = getAnnotatedPromise();
  console.log(annotatedPromise.myValue); // undefined
  console.log(await annotatedPromise); // hello
})();

TypeScript Playground link

Awaiting... anything

Another interesting property of the await keyword is that it can be used with anything. It is not limited to Promises and Promise-like objects. You can await numbers, strings, objects, arrays, functions, errors, even null and undefined.

console.log([
  await Promise.resolve(1),
  await 2,
  await "three",
  await (() => 4),
  await [5],
  await { num: 6 },
  await null,
  await undefined,
]);

TypeScript playground link

JavaScript will not complain. It will actually await Promises, and will return all the other values unmodified.

Awaiting... problems

This is an example of JavaScript being friendly to impreciseness. Instead of enforcing strict requirements, it errs on the side of defensive programming. Instead of forcing application code to handle ambiguity if it is required, the handling of ambiguity is ingrained in the language.

If the rules of async/await were changed to avoid any implicit awaiting/coercions, the problems mentioned in this article would go away.

A better future awaits (in an alternate universe)

If async functions were to be added to the language again, I would be an advocate of designing them the following way:

The return value of an async function call is whatever was returned inside the function body, just wrapped in a Promise (because the function is async, after all). awaiting anything that is not Promise-like throws an error.

A function like

async function workflow(): Promise<Promise<number>> {
  return Promise.resolve(1);
}

would return Promise<Promise<number>>. If the value should be flattened, add await before returning.

async function workflow(): Promise<number> {
  return await Promise.resolve(1);
}

This would automatically solve the problem of try not catching Promises returned from an async function.

Since awaiting non-Promises leads to errors, preciseness is enforced and makes handling different types of values more explicit.

I am not a language designer and I am not a part of the TC39 group. I am sharing my thoughts on Promises and async/await. Hindsight is 20/20.