Async await like syntax, without ppx in Rescript !!!

Praveenkumar
September 29, 2021

async

Prerequisite:

  • Basic understanding of functional programming.
  • Basic knowledge on Rescript/ReasonML.

The code and ideas that I will be discussing about in this article are my own opinions. It doesn't mean this is the way to do it, but it just means that this is also a way to do it. Just my own way.

In Rescript, currently (at the time of writing this article) there is no support for async/await style syntax for using promises. You can read about it here. Even though Rescript's pipe syntax make things cleaner and more readable, when it comes to working with promises there is still readability issues due to the lack of async/await syntax. There are ppx available to overcome this issue. But what if, we can overcome this issue without using any ppx.

Lets first look at, how existing promise chaining looks like in Rescript.

fetchAuthorById(1)
  |> Js.Promise.then_(author => {
    fetchBooksByAuthor(author) |> Js.Promise.then_(books => (author, books))
  })
  |> Js.Promise.then_(((author, books)) => doSomethingWithBothAuthorAndBooks(author, books))

In the above code, to access both author and books, I am creating a tuple to be passed into the next promise chain and using it there. This can easily grow and become more cumbersome when we chain three or more levels.

The idea is to,

Create a function that takes multiple promise functions as labelled arguments, executes them sequentially and stores each result as a value in an object, with labels as keys of the object

This idea is inspired from Haskell's DO notation. Lets see, how this function looks like.

type promiseFn<'a, +'b> = 'a => Js.Promise.t<'b>

let asyncSequence = (~a: promiseFn<unit, 'a>, ~b: promiseFn<{"a": 'a}, 'b>) =>
  a()
  |> Js.Promise.then_(ar => {"a": ar}->Js.Promise.resolve)
  |> Js.Promise.then_(ar =>
    ar
    ->b
    ->map(br =>
      {
        "a": ar["a"],
        "b": br,
      }
    )
  )

Lets understand what this function is doing.

  1. A type called promiseFn is defined, that takes some polymorphic type 'a and returns a promise of type 'b.
  2. asyncSequence function takes two labelled arguments a and b which are of type promiseFn.
  3. Argument a is a function that takes nothing, but returns a promise of 'a.
  4. Argument b is a function that takes an Object of type {"a": 'a} where the key a corresponds to the label a and the value 'a corresponds to the response of the function a.
  5. a is first invoked and from its response an Object of type {"a": 'a} is created and passed into function b. The response of function b is taken and an object of type {"a": 'a, "b": 'b} is created.

The above function, chains only 2 promise functions. But, using this method we can create functions that chains multiple promise functions.

// Takes 3 functions
let asyncSequence3 = (
  ~a: promiseFn<unit, 'a>,
  ~b: promiseFn<{"a": 'a}, 'b>,
  ~c: promiseFn<{"a": 'a, "b": 'b}, 'c>,
) =>
  asyncSequence(~a, ~b) |> Js.Promise.then_(abr =>
    abr->c
      |> Js.Promise.then_(cr =>
        {
          "a": abr["a"],
          "b": abr["b"],
          "c": cr,
        }->Js.Promise.resolve
      )
  )

// Takes 4 functions
let asyncSequence4 = (
  ~a: promiseFn<unit, 'a>,
  ~b: promiseFn<{"a": 'a}, 'b>,
  ~c: promiseFn<{"a": 'a, "b": 'b}, 'c>,
  ~d: promiseFn<{"a": 'a, "b": 'b, "c": 'c}, 'd>,
) =>
  asyncSequence3(~a, ~b, ~c) |> Js.Promise.then_(abcr =>
    abcr->d
      |> Js.Promise.then_(dr =>
        {
          "a": abcr["a"],
          "b": abcr["b"],
          "c": abcr["c"],
          "d": dr,
        }->Js.Promise.resolve
      )
  )

// .... Any level

See, we are using previous asyncSequence3 to define next level asyncSequence4. To understand this function better, lets see how it is used. Lets rewrite our previous example using this asyncSequence4.

asyncSequence4(
  ~a=() => fetchAuthorById(1),
  ~b=arg => fetchBooksByAuthor(arg["a"]),
  ~c=arg => doSomethingWithBothAuthorAndBooks(arg["a"], arg["b"]),
  ~d=arg => Js.log(arg)->Js.Promise.resolve
)

// Response of asyncSequence4 will be a promise of type
// {
//  "a": <Author>,
//  "b": <BooksArray>,
//  "c": <Response of doSomethingWithBothAuthorAndBooks>
//  "d": <unit, since Js.log returns unit>
// }

What is happening is, the response of fetchAuthorById is taken and an object of type {"a": <Author>} is created. This object is passed to function b as arg and hence that function b has access to previous function a's result. Now the response of b is merged together with response of a into a single object as {"a": <Author>, "b": <BooksArray>} and passed to function c as argument arg. Now function c has access to both the response of a as well as response of b in the object that is received as argument. This is continued down the path to function d.

With this approach the chaining is easy and multiple asyncSequence can be chained like below, which can provide access to all the previous values.

let promiseResp = asyncSequence4(
  ~a=() => fetchAuthorById(1),
  ~b=arg => fetchBooksByAuthor(arg["a"]),
  ~c=arg => doSomethingWithBothAuthorAndBooks(arg["a"], arg["b"]),
  ~d=arg => Js.log(arg)->Js.Promise.resolve
)

asyncSequence(
  ~a=() => promiseResp,
  ~b=arg => doSomethingWithAllThePreviousResponse(arg["a"])
)

Before we jump into pros and cons of this approach, lets see one common mistake that can happen.

asyncSequence3(
  ~a=() => firstExecution(),
  ~c=_ => thirdExecution(),
  ~b=_ => secondExecution(),
)

The above code will compile fine. It is easy to think that c will be executed after a, but thats not true. The execution will always happen from a to z even though the order is changed.

Now, lets see what are the pros and cons of this approach.

Pros:

  1. Far more readable than the raw Promise chaining.
  2. Somewhat similar to the js async await syntax.
  3. Each function down the line has access to all the previous responses.
  4. No PPX and no additional dependencies needed.
  5. Completely type safe. Compiler will raise errors of any wrong usage.
  6. One asyncSequence can be chained to the next asyncSequence easily.

Cons:

  1. Multiple overloaded functions required.
  2. Order must not be changed.
  3. Keys of the object cannot be changed ("a", "b" ... will always be the keys).

You can check the refactored, full code here.

Hope you enjoyed! Happy Hacking!

functional-programmingrescriptReasonMLPromise

WRITTEN BY

Praveenkumar

I am a passionate full stack developer. I develop primarily using JS specifically TS. I like exploring latest languages, technologies and frameworks. Currently trying out Rescript and I like it a lot.