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.
type
called promiseFn
is defined, that takes some polymorphic type 'a
and returns a promise of type 'b
.asyncSequence
function takes two labelled arguments a
and b
which are of type promiseFn
.a
is a function that takes nothing, but returns a promise of 'a
.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
.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.
asyncSequence
can be chained to the next asyncSequence
easily.You can check the refactored, full code here.
Hope you enjoyed! Happy Hacking!
WRITTEN BY
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.