A better interop story with JS Promises

As with most things in life, when you get more experienced, you improve. The bulk of my work in the past few years has been on mobile and back-end. I’m doing a lot more work on the browser lately, learning new things every day. Since my last Kotlin JS / Promises post, I discovered an interesting trick I’d like to share.

The async and await keywords (see MDN Web Docs) act as syntactic sugar on top of promises, making asynchronous code a lot easier to write and read.

And, as a lot of the standard JS APIs, Promise’s async/await is also available to Kotlin-JS developers.

1
2
3
4
5
6
7
/**
 * Awaits for completion of the promise without blocking.
 *
 * This suspending function is cancellable.
 * ...
 */
public suspend fun <T> Promise<T>.await(): T // ...

Not only do we get a nice suspend extension function, but it’s cancellable. Let’s see how we can use this in our code.

Flattening your pyramids

Here, I’m re-using an example from my last post. A straightforward fetch() to grab a bit of JSON off our server.

1
2
3
4
5
6
7
fun fetchInit() {
   window.fetch("/__/firebase/init.json")
      .then { res: Response -> res.json() }
      .then { config: Any? -> config.unsafeCast<Json>() }
      .then { jsonConfig: Json -> firebaseInitApp(jsonConfig) } // Exec thread goes 🐇🕳
      .catch { throwable -> console.log(throwable) }
}

Goodbye execution flow. Hope you remember our Promise...

There’s nothing wrong with using Promises this way. The above is a nice, tidy bit of code. On the other hand, we do lose ‘initiative’. We set up the Promise callback code and move anything else that needs to happen into that callback.

The main problem here is readability. Moving execution flow into callbacks can quickly make the execution flow hard to follow, jumping from one callback to another as your program grows in complexity.

Let’s rewrite this example using the Kotlin-JS Promise.await() extension function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
launch { fetchInit() }
suspend fun fetchInit() {
   try {
      // Sequential calls are easier to reason about.
      val response: Response = window.fetch("/__/firebase/init.json").await()
      val firebaseConfig: Json = response.json().await().unsafeCast<Json>()
      firebaseInitApp(firebaseConfig)
   } catch (throwable: Throwable) { 
      console.error(throwable) // Only need a single catch().
   }
}

Since await() is a suspend function, our code is now handling things sequentially. It makes our program execution flow easier to understand.

We can also simplify things further, thanks to function chaining.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Chaining calls is also an option.
suspend fun fetchInit() {
   try {
      window
         .fetch("/__/firebase/init.json").await()
         .json().await().unsafeCast<Json>()
         .also { firebaseConfig -> firebaseInitApp(firebaseConfig) }
   } catch (throwable: Throwable) {
      console.error(throwable)
   }
}

There are a few schools of thought on this. Personally, I find method chaining tends to make my code cleaner and more concise. In this example, we got rid of two intermediate vals that we did not need. And, after this, it becomes apparent how we could make this function generic.

Under the hood

My earlier approach was to use Kotlin’s suspendCoroutine() function to wrap the fetch() -> json() call sequence into a single function called suspendFetch().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Suspending fetch function using `suspendCoroutine {}`
suspend fun suspendFetch(input:String): Json? {
    return suspendCoroutine { continuation ->
        window.fetch(input).then { res ->
            res.json().then { config ->
                continuation.resume(config.unsafeCast<Json>())
            }
        }.catch { throwable ->
            console.log(throwable)
            continuation.resume(null)
        }
    }
}

This function works fine. But now that we have await() in our toolbox, we could try a different approach.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Suspending fetch function using `await()`
suspend fun suspendFetch(input: String): Json? {
   return try {
      window
         .fetch(input).await()
         .json().await().unsafeCast<Json>()
   } catch (t: Throwable) {
      console.error("Could not complete fetch, returning null. %o", t)
      null
   }
}

Somewhat easier to read. But is it worth changing our approach just for a small readability gain? Well, as it happens, there’s a hidden benefit here! Let’s looking under the hood of the Promise<T>.await() extension function:

1
2
3
4
5
6
// Under the hood, in `Promise.kt`:
public suspend fun <T> Promise<T>.await(): T = suspendCancellableCoroutine { cont: CancellableContinuation<T> ->
    this@await.then(
        onFulfilled = { cont.resume(it) },
        onRejected = { cont.resumeWithException(it) })
}

Because Promise<T>.await() uses suspendCancellableCoroutine {}, the new version of suspendFetch() function will now gracefully handle coroutine cancellations. By relying on the Kotlin platform, we get better-structured concurrency for free.

Of course, we could modify our original function to use suspendCancellableCoroutine {} as well. But for my money, I’d rather retire my old code when the Kotlin platform itself provides me with a close equivalent.

Further reading

That’s it for today. If you want to learn more, here are a few key references to look at:

Also, big thanks to Parth Padgaonkar and Tiger Oakes for their help proofreading this article.