Promises, async, and await in ReScript (with Bun!)

Josh Derocher-Vlk - Sep 26 '23 - - Dev Community

ReScript is "Fast, Simple, Fully Typed JavaScript from the Future"

Let's take a look at how to use JavaScript promises, async, and await in ReScript using Bun v1 to quickly run and see our changes.

ReScript

ReScript is a strongly typed language with a JavaScript like syntax that compiles to JavaScript.

Getting set up

We'll be using Bun as our package manager and to run our code as we work.

  • If you don't already have Bun installed go ahead and run npm i bun -g.
  • Create a new folder and open up VSCode or your IDE of choice.
  • Install the ReScript extension for your IDE.
  • Set up your project with bun init. Set the entry point to src/index.mjs.
  • Install ReScript: bun install rescript@next @rescript/core

Create a bsconfig.json file to configure ReScript:

{
    "name": "bun-rescript",
    "sources": [
        {
            "dir": "src",
            "subdirs": true
        }
    ],
    "package-specs": [
        {
            "module": "es6",
            "in-source": true
        }
    ],
    "suffix": ".mjs",
    "bs-dependencies": [
        "@rescript/core"
    ],
    "bsc-flags": [
        "-open RescriptCore"
    ]
}
Enter fullscreen mode Exit fullscreen mode

Create a src/index.res file that logs something to the console:

Console.log("starting...")
Enter fullscreen mode Exit fullscreen mode

Run bun rescript build -w in one terminal tab or window and bun --watch src/index.mjs in another.

You now have ReScript quickly compiling the .res file into a .mjs file in a few milliseconds and then Bun running that code in a few milliseconds. This is a very nice quick feedback loop to use for rapid development.

Promises

I will assume you have a basic working knowledge of Promises in JavaScript.

Here's a really basic example of a Promise in ReScript:

let main = () => {
  let _ = Promise.resolve(42)->Promise.then(n => Console.log(n)->Promise.resolve)
}

main()
Enter fullscreen mode Exit fullscreen mode

Let's walk through each part of the code here.

Let's start by understanding what's going on with main and the let _ = part.

let main = () => {
  let _ = ...
}

main()
Enter fullscreen mode Exit fullscreen mode

In ReScript every line of code is an expression and the last expression in a function is the return value.

let fn = () => {
  42 // this function returns 42
}
Enter fullscreen mode Exit fullscreen mode

Note: even though we aren't adding type annotations, ReScript is able to always correctly infer the types so this function has a type signature of unit => int. unit in ReScript means that we have no value at all, so this function takes in no parameters and returns an int.

Any top level expression has to have a type of unit in ReScript, which means we can't return something in a top level function call like what we are doing with main(). So our we have to make sure it returns a type of unit and we can do that by assigning the value to _. _ is a special syntax for a value that exists but we never intend to use. If we did let x = ... the compiler would warn us that x is never used.

Creating the promise looks identical to JavaScript:

Promise.resolve(42)
Enter fullscreen mode Exit fullscreen mode

The next part is different from JS. In ReScript we don't have dot style chaining so we can't do Promise.resolve(42).then(...). ReScript has pipes, which we use with the -> operator. So we take the Promise we created and "pipe" it into the next step, which is Promise.then.

Promise.resolve(42)->Promise.then(...)
Enter fullscreen mode Exit fullscreen mode

And inside Promise.then we are logging to the console and returning the result (which is unit) as a Promise. In ReScript every Promise.then has to return another Promise. JavaScript Promises do some magic to handle returning a value or another Promise inside of .then, and since a type can only every be one thing in ReScript we have to commit to always explicitly returning a Promise. Thankfully the Promise module has a thenResolve function that can clean this up.

let main = () => {
  let _ = Promise.resolve(42)->Promise.thenResolve(n => Console.log(n))
}

main()
Enter fullscreen mode Exit fullscreen mode

Switching to async/await

This function can never throw an error so we don't need to worry about .catch so we can safely convert it to async/await syntax. If you want to avoid runtime errors you shouldn't use async/await unless you want to wrap it in a try/catch block, which can get ugly real quick.

In ReScript async/await works pretty much the same as in JavaScript. Since we're now expecting main() to return a Promise we can remove the let _ = ... part and replace it with await.

let main = async () => {
  await Promise.resolve(42)->Promise.thenResolve(n => Console.log(n))
}

await main()
Enter fullscreen mode Exit fullscreen mode

Let's make it do something

Instead of returning a static number and logging it let's take in a number and validate that it is between 0 and 100. If it's out of bounds we want to return an have it log an error.

let main = async n => {
  await Promise.resolve(n)
  ->Promise.then(n =>
    n >= 1 && n <= 100
      ? Promise.resolve(n)
      : Promise.reject(Exn.raiseError("number is out of bounds"))
  )
  ->Promise.thenResolve(n => Console.log(n->Int.toString ++ " is a valid number!"))
}

await main(10)
Enter fullscreen mode Exit fullscreen mode

We should see 10 is a valid number! in the Bun console, but we didn't properly handle the error if we give it an invalid number so we get a runtime exception.

4 | function raiseError(str) {
5 |   throw new Error(str);
            ^
error: number is out of bounds
      at raiseError
Enter fullscreen mode Exit fullscreen mode

We can improve this by using ReScript's Result type, which is a variant type that is either Ok or an Error.

let main = async n => {
  await Promise.resolve(n)
  ->Promise.thenResolve(n =>
    n >= 1 && n <= 100
      ? Ok(n->Int.toString ++ " is a valid number!")
      : Error(n->Int.toString ++ " is out of bounds")
  )
  ->Promise.thenResolve(res =>
    switch res {
    | Ok(message) => Console.log(message)
    | Error(err) => Console.error(err)
    }
  )
}

await main(1000) // => 1000 is out of bounds
await main(10) // => 10 is a valid number!
Enter fullscreen mode Exit fullscreen mode

Wrapping up

You should now have a basic understanding of how to use Promises in ReScript. The part that I think is key is that we don't have to throw errors in our promises because we have the Result type. It's a better developer and user experience to capture known errors and handle them gracefully by returning an it in an API response or rendering an error to a user in a React application.

Unknown exceptions will happen of course, but in this case we expect that the number could be invalid. What if our function was defined here and meant to used somewhere else? Let's rewrite it to return either return the number or an error message.

let validateQuantity = async n => {
  await Promise.resolve(n)->Promise.thenResolve(n =>
    n >= 1 && n <= 100
      ? Ok(n)
      : Error(n->Int.toString ++ " is out of bounds and is not a valid quantity.")
  )
}
Enter fullscreen mode Exit fullscreen mode

Now the function will return promise<result<int, string>> so anyone using this knows that we expect an error case and can handle it appropriately.

We can even make the error messages have more meaning if we change this to use pattern matching:

let validateQuantity = async n => {
  await Promise.resolve(n)->Promise.thenResolve(n =>
    switch [n >= 1, n <= 100] {
    | [false, _] => Error(n->Int.toString ++ " is less than 0 and is not a valid quantity.")
    | [_, false] => Error(n->Int.toString ++ " is greater than 100 and is not a valid quantity.")
    | _ => Ok(n)
    }
  )
}
Enter fullscreen mode Exit fullscreen mode

This would allow us to show a meaningful error message to a user if they try and do something invalid.

let validateQuantity = async n => {
  await Promise.resolve(n)->Promise.thenResolve(n =>
    switch [n >= 1, n <= 100] {
    | [false, _] => Error(n->Int.toString ++ " is less than 1 and is not a valid quantity.")
    | [_, false] => Error(n->Int.toString ++ " is greater than 100 and is not a valid quantity.")
    | _ => Ok(n)
    }
  )
}

let addToCart = async quantity => {
  let validatedQuantity = await validateQuantity(quantity)
  switch validatedQuantity {
  | Ok(n) => Console.log(n->Int.toString ++ " items successfully added to cart!")
  | Error(e) => Console.error(e)
  }
}

await addToCart(10) // => 10 items successfully added to cart!
await addToCart(1000) // => 1000 is greater than 100 and is not a valid quantity.
await addToCart(0) // => 0 is less than 1 and is not a valid quantity.
Enter fullscreen mode Exit fullscreen mode

Questions?

Please feel free to ask anything in the comments!

. . . . . . . . .