Exploring ReScript on Deno

Since the rebrand of ReScript, it no longer has a blessed way for building and distributing ReScript tools as native applications. In this post, I explore how to use ReScript with Deno, the modern runtime for TypeScript and Javascript.

Exploring ReScript on Deno

Since the rebrand of ReScript, it no longer has a blessed way for building and distributing ReScript tools as native applications. Instead, we are tied to the Node.js runtime and tooling to do this.

In this post I explore what it would take to use ReScript with Deno to build single binaries out of our ReScript applications that have:

  • Fast boot time
  • No external dependencies
  • Control for what they can do (for ex. can they write to disk?)
  • Can rely on a large, high-quality standard library

Consider this a Preliminary Experience Report™️.

The Land of Deno

Deno is sort of a sequel to Node. Imagine if The Matrix Reloaded was better than the original one.

It brands itself as a modern runtime for JavaScript and TypeScript, but folks seem to be running all sorts of compile-to-js languages...and so am I! Some other people are even running whatever targets WebAssembly, but we'll skip that bit for now.

Deno is written in Rust, so you can bet that it is a fast tool to use. It has every developer tool you will need built-in — a formatter, a bundler, a native compiler, a testing tool, and more. It even supports TypeScript by default. It's got secure capabilities to make sure programs don't do any unintended side-effects, so if your app needs to read from the network you need to explicitly let it with the --allow-net flag. And it also changes the way package management works for the average Javascript application, since everything is fetched on use by its URL and locally cached. Oh, on top of this, it comes with a large standard library written in TypeScript.

I chose Deno to write the tooling for my upcoming book (Practical ReScript) primarily because:

  1. I can avoid the dependency jungle of the Javascript world
  2. I can rely on a large standard library to build on top of
  3. If needed, I can rely on libraries built on other languages (like Rust) through their WebAssembly bindings

So let's get our hands dirty and write some ReScript on Deno.

Hello, Deno!

First off, we'll do a simple Hello World program to get the flow running. If you want to follow along, the setup of this project is just a single rescript init.

I ran rescript init hello-deno and got this:

$ cd hello-deno
$ ls ./*
.rw-r--r-- 109 ostera wheel 30 Jan 20:27 bsconfig.json
.rw-r--r-- 288 ostera wheel 30 Jan 20:27 package.json
.rw-r--r--  65 ostera wheel 30 Jan 20:27 README.md

src:
.rw-r--r-- 26 ostera wheel 30 Jan 20:27 demo.res

Great, there are 2 tweaks we need to make here.

The first change is to make ReScript output ES6 instead of CommonJS modules. This is because Deno runs on ES6, so while there's no built-inrequire, there is first-class support for import and export. We need to change our bsconfig.json to reflect this. Here's what mine looks like:

{
  "name": "hello-deno",
  "version": "0.1.0",
  "sources": {
    "dir" : "src",
    "subdirs" : true
  },
  "package-specs": [
    {
      "module": "es6",
      "in-source": false
    }
  ]
}

The second tweak is a small configuration in Deno. In Deno, all dependencies are either local paths or are absolute URLs that you can fetch from the internet. ReScript builds our modules to pull from the node_modules folder by package name, which is the default and expected behavior in Node. To fix this, Deno allows you to specify an Import Map. An Import Map is a JSON file with module names or prefixes for keys, and local paths for values. It tells Deno that every time someone does an import "name/...", the name/ part needs to be replaced by either a URL or a file path.

Here's mine, I'm calling it imports.json:

{
  "imports": {
    "rescript/": "./node_modules/rescript/"
  }
}

Here we are asking Deno to replace rescript/ with ./node_modules/rescript/ every time it sees it in an import.

Alright, with those 2 changes, we are absolutely ready to get started.

Let's run yarn to get ReScript installed, rescript build to build our project, and deno compile to get our binary built:

$ yarn
yarn install v1.22.17
info No lockfile found.
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
✨  Done in 2.94s.

$ rescript build
rescript: [3/3] src/demo.cmj

$ deno compile --import-map=imports.json ./lib/es6/src/demo.js 
Compile file:///private/tmp/hello-deno/lib/es6/src/demo.js
Emit demo

$ ls demo      
.rwxrwxrwx 83M ostera wheel 30 Jan 20:35 demo

$ ./demo 
Hello, ReScript	

Amazing! With just those 2 config changes, we are able to build a binary out of our ReScript code.

How fast is this really, compared to Node?

I used Hyperfine to test this example, specifically to see what is the difference in start-up time. There are a lot of other things to consider, and this is definitely not representative of larger application performance, but to get an idea of how fast this gets to the actual work, it is a good thumbsuck.

Here's the raw results:

$ hyperfine --warmup 1000 "./demo" "node ./bundle.js"
Benchmark 1: ./demo
  Time (mean ± σ):      15.2 ms ±   7.1 ms    [User: 9.0 ms, System: 2.8 ms]
  Range (min … max):    12.6 ms …  68.8 ms    182 runs
 
  Warning: Statistical outliers were detected. Consider re-running this benchmark on a quiet PC without any interferences from other programs. It might help to use the '--warmup' or '--prepare' options.
 
Benchmark 2: node ./bundle.js
  Time (mean ± σ):      27.5 ms ±   1.6 ms    [User: 23.2 ms, System: 3.5 ms]
  Range (min … max):    26.6 ms …  40.3 ms    98 runs
 
  Warning: Statistical outliers were detected. Consider re-running this benchmark on a quiet PC without any interferences from other programs. It might help to use the '--warmup' or '--prepare' options.
 
Summary
  './demo' ran
    1.82 ± 0.86 times faster than 'node ./bundle.js'

We can see that it is faster, about 1.82 times faster than running a bundled version of the app (bundled with deno bundle).

Keep in mind that this is not representative of larger application performance.

If you do feel that Deno is considerably faster across the board, remember that the tools within Deno are written in Rust, so things like formatting are considerably faster than what you're used to with tools like Prettier.

The Deno Standard Library

In our first example, we just relied on the default Js.log bindings provided by ReScript. Let's try again now but using the standard logger that comes with Deno's stdlib.

To do this we will need to write our own bindings.

We'll begin by creating a new module called Std, and another one inside called Log. These map one-to-one to the path to the actual Deno stdlib modules.

module Std = {
  module Log = {
  }
}

Inside Std.Log, we'll create a new external value called info, and we will tell the compiler this value is coming from module, but the module name will be a URL. This is where the Deno package system can trip you if you're used to referencing things locally.

module Std = {
  module Log = {
    @module("https//deno.land/std@0.123.0/log/mod.ts")
    @val external info
  }
}

The rest is the signature we expect this function to have, so the type-system can fit it into the rest of our code. In this case, our info function takes any value and returns a unit.

module Std = {
  module Log = {
    @module("https//deno.land/std@0.123.0/log/mod.ts")
    @val external info: 'a => unit = "info"
  }
}

Finally, we open the Std module, and call the logger with a greeting to Deno.

This is what our file should look like now:

module Std = {
  module Log = {
    @module("https://deno.land/std@0.123.0/log/mod.ts")
    @val external info: 'a => unit = "info"
  }
}

open Std
Log.info("Hello, Deno!")

Once we're here, we can call rescript build, and check the resulting Javascript:

// Generated by ReScript, PLEASE EDIT WITH CARE

import * as ModTs from "https://deno.land/std@0.123.0/log/mod.ts";

var Log = {};

var Deno = {
  Log: Log
};

ModTs.info("Hello, Deno!");

export {
  Deno ,
  
}
/*  Not a pure module */

Not too shabby! There are a few unused empty objects, but they aren't a big deal. The one strange thing is that the module was imported using a name generated from the URL. ModTs comes from the last bit of the URL, where it goes .../log/mod.ts. This could seem like a problem, but if you import many that end in the same way, ReScript will automatically add numbers to them to disambiguate.

Next up, we're going to compile this with the same command we used before:

$ deno compile --import-map=imports.json ./lib/es6/src/demo.js  
Download https://deno.land/std@0.123.0/log/mod.ts
Download https://deno.land/std@0.123.0/_util/assert.ts
Download https://deno.land/std@0.123.0/log/handlers.ts
Download https://deno.land/std@0.123.0/log/levels.ts
Download https://deno.land/std@0.123.0/log/logger.ts
Download https://deno.land/std@0.123.0/fmt/colors.ts
Download https://deno.land/std@0.123.0/fs/exists.ts
Download https://deno.land/std@0.123.0/io/buffer.ts
Download https://deno.land/std@0.123.0/bytes/bytes_list.ts
Download https://deno.land/std@0.123.0/bytes/mod.ts
Download https://deno.land/std@0.123.0/io/types.d.ts
Download https://deno.land/std@0.123.0/bytes/equals.ts
Check file:///private/tmp/hello-deno/lib/es6/src/demo.js
Bundle file:///private/tmp/hello-deno/lib/es6/src/demo.js
Compile file:///private/tmp/hello-deno/lib/es6/src/demo.js
Emit demo

Notice how the first thing that happens when compiling is a dependency graph traversal, where Deno finds all the dependencies that are actually needed to run our program, downloads them, compiles them, and caches them.

If we run the same compile command again, we get this:

$ deno compile --import-map=imports.json ./lib/es6/src/demo.js         
Check file:///private/tmp/hello-deno/lib/es6/src/demo.js
Bundle file:///private/tmp/hello-deno/lib/es6/src/demo.js
Compile file:///private/tmp/hello-deno/lib/es6/src/demo.js
Emit demo

No downloads needed again for that URL. We can force download with the --reload flag if we really want to.

Now we can check and run our executable:

$ ls demo
.rwxrwxrwx 83M ostera wheel 30 Jan 20:58 demo
$ ./demo 
INFO Hello, Deno!

And that's it! We got ReScript code running on Deno, using the Deno standard library.

In Conclusion

So far I am really enjoying working with ReScript on Deno. It is not the premium experience you get on a super polished platform, but it's got tons of potential.

In writing the tooling for the book I've had to play around with a few concepts that aren't that common in everyday ReScript, like AsyncGenerators, or ReadableStreams, and those can get a little tricky to type.

But as the bindings I write get more precise, the experience gets better and better.

A screenshot of some of the bindings I've been writing to use the Deno standard library from ReScript

Right now I'm at a point where all the I/O I need is covered, so it just feels like writing ReScript, except it boots in ~14ms.

I'd recommend you to try it out, or check out these posts where I explore:

  • how to write CLIs
  • how to reach out to Rust code to compile Markdown
  • how to build a static file server

and more, all running ReScript on Deno.