8th January 2019 19 min read

How to get a taste of ReasonML by building something useful

Seif Ghezala

ReasonML, a programming language that compiles to JavaScript, was built by extending the functional programming language OCaml, leveraging powerful and safe types smartly inferred by default. Reason’s official documentation is clear, yet covering the theory with “Hello World” examples won’t make you properly experiment with the language.

This article does not aim to showcase all Reason’s features nor convince you whether to use it. It will rather walk you through a realistic application of Reason to learn plenty of its features and get a real taste of the language’s interoperability with NodeJS.

We will use Reason to build a tree-cli-like NodeJS tool that lists a directory’s content in the shape of a tree.

This article does not require any prior knowledge of Reason as it will cover everything from setting up the editor to implementing the tool.

The final source code can be found here.

NodeJS >= 10.x is required

There are various editor options that can support us in writing Reason code. In this section, we will see how to set up Visual Studio Code.

Once you have Visual Studio Code installed, you can go ahead and install the plugin reason-vscode , which offers helpful features like:

  • Types display
  • Errors & warnings display
  • Syntax highlighting
  • Type-driven autocomplete
  • Jump to definition
  • Automatic code formatting (similar to Prettier)

To take advantage of the formatting feature, let’s enable the Format on Save option in the editor’s settings:

Enabling the Format On Save option on VSCode

Let’s go ahead and install BuckleScript, a tool to compile Reason code into JavaScript:

text
1npm install -g bs-platform

The installation comes with a CLI tool bsb that helps to bootstrap a Reason project. It provides out of the box support for multiple environments, called themes. In our case, we are only interested in the default Reason theme: basic-reason . We can use it to generate our project, that we will call reason-tree :

text
1bsb -init reason-tree -theme basic-reason

Let’s first go ahead and open our bootstrapped project. In the src folder, there should be a Demo.re file. This is a simple Reason file that holds our code:

text
1Js.log("Hello, BuckleScript and Reason!");

We can run the project in watch mode by running the NPM start script:

text
1npm start

This will create a Demo.bs.js file in the same directory and will update it whenever we make any changes to Demo.re file. This file simply hosts the JavaScript compiled code of our Demo.re file.

text
1src/2  Demo.re3  Demo.bs.js

We can go ahead and run it with node:

text
1node src/Demo.bs.js

We should then see a nice Reason hello world output on the terminal 🎉

text
1Hello, BuckleScript and Reason!

Before reading the content of a directory specified by the user, we will need to get its absolute path. Let’s try to write a function that does so:

reasonml
1let getAbsolutePath = (path:string) => {2  let absolutePath = "some/absolute/path";3  absolutePath;4}5Js.log(getAbsolutePath("./"));

The function for now only returns a constant text and looks very similar to a normal JavaScript function with some exceptions:

  • The argument path has a type annotation string . We actually don’t have to do that because ReasonML can most of the time infer types by itself. However, it’s a good practice to annotate function arguments for readability and to increase the accuracy of type checking.
  • There is no return statement. Instead, the last expression (absolutePath) is what gets returned automatically.

We want to keep our Demo.re file separated from any logic, such as getting the absolute path of a directory.

To do so, we will create a separate Util module that will hold our utility functions. Let’s go ahead and create the file Util.re and copy-paste the getAbsolutePath function.

text
1src/2  Demo.re3  Demo.bs.js4  Util.re5  Util.bs.js
Notice: a Util.bs.js file got created automatically. This is because Reason creates a compiled JavaScript file per module.

Now, how do we access the Util module from Demo.re ? 🤔

Guess what? Modules can automatically be consumed by their names in ReasonML. So in our Demo.re file, we simply write the following:

reasonml
1Js.log(Util.getAbsolutePath("some/path"));

For now, getAbsolutePath only returns a fixed text. To get the actual absolute path from a certain path given by the user, let’s first see how it’s done in Node. You can usually do this by simply using the path module:

reasonml
1// we require the path module2const path = require('path');3
4// let somePath be a path given by the user5const somePath = './';6
7// __dirname is the current path8let absolutePath = path.resolve(__dirname, somePath);

The bs-platform module we installed earlier comes with bindings to Node. The bindings can be accessed through the module Node and they include most of the modules Node ships with such fs and path .

path.resolve is therefore already available through Node.Path.resolve . If you hover through Node.Path.resolve , you’ll see its type signature:

text
1(string, string) => string

This tells us that Node.Path.resolve receives 2 strings and returns the absolute path as a string.

To obtain the __dirname value, we will have to create a binding by ourselves using the external utility provided by Reason.

To understand how to use the external utility, let’s see a simple example. We would like to access a global value isFoo that is of type bool in Node. We want it to be available in our Reason code under the name isBar . The syntax for accessing the value would then be the following:

reasonml
1[@bs.val] external isBar:bool = "isFoo"

In our case, we want to access the global value dirname and we want it to be available under the name `dirname` in our Reason code:

reasonml
1[@bs.val] external __dirname:string = "__dirname"

Actually, since the name of our Reason alias for the value is the same as the value name in Node, we can keep the string body empty as a shorthand to achieve the same thing:

reasonml
1[@bs.val] external __dirname:string = ""

Now that we have __dirname available and we know how to access the path module, let’s implement properly our getAbsolutePath function:

reasonml
1[@bs.val] external *__dirname*:string = "";2let getAbsolutePath = (path:string) => {3  Node.Path.resolve(__dirname, path);4};

Now let’s test it in our Demo.re file:

reasonml
1Js.log(Util.getAbsolutePath("./"));

If we run the compiled Demo.bs.js, we should see the absolute path of the current directory.

We’ve successfully completed the first step of the project and we can now get the absolute path from a certain directory path given by the user.

We should now look into how we can read the directory’s content. The Node fs module provides a function to do just that: readdir . To simplify things, we will use the synchronous version of the function readdirSync .

Let’s try out the function and see what we can print. We will create a function printDir: string => unit that will simply print the content of a directory.

You’re probably wondering about the type signature: what is this unit type? Since all values in ReasonML need to have a type, when a function doesn’t return anything, its return type is unit . Similarly, if a function does not receive any argument, its argument type is unit.

reasonml
1let printDir = (dirPath:string) => {2  let absolutePath = getAbsolutePath(dirPath);3  let contentArray = Node.Fs.readdirSync(absolutePath);4  let contentList = Array.to_list(contentArray);5
6  List.iter((item:string) => {7    Js.log("name: " ++ item);8  }, contentList)9}

Node Fs.readdirSync returns an array of string. To iterate through the array and print each item, we convert to a list and we use the List.iter function which is the equivalent of the JavaScript forEach.

If we take a close look at printDir, we can see it as a chain of operations that receive input and pass it over:

text
1dirPath -> getAbsolutePath -> Node.Fs.readdirSync -> to_list -> List.iter
  • getAbsolutePath receives dirPath and converts it to an absolutePath before passing it over to Node.Fs.readdirSync.
  • Node Fs.readdirSync receives the absolutePath and turns it into an array of file/directory names before passing it to to_list .
  • to_list receives the array of string items and turns it into a list of string before sending it to List.iter.
  • List.iter receives the list of string items and iterates through them to print each one.

We can take advantage of the Reason pipe operator to refactor this code and make it more concise and readable:

reasonml
1let printDir = (dirPath:string) => {2  dirPath3  |> getAbsolutePath4  |> Node.Fs.readdirSync5  |> Array.to_list6  |> List.iter((item:string) => {7      Js.log("name: " ++ item)8  })9}

So far, we managed to print the names of the files & directories directly present in some directory path given by the user.

We now want to show to the user whether each item in the directory is a file or a directory. The readdirSync function in Node accepts an option withFileTypes that when passed, will return a list of Dirent objects containing the information we need about each item in the directory. In fact, a Dirent object has the following data:

  • name : the name of the file/directory item
  • isDirectory() : a function that returns a boolean about whether the item is a directory or a file

This is where we encounter our first obstacle with the provided bindings to Node. Unfortunately, the provided Node Fs.readdirSync function doesn’t provide the possibility to pass a withFileTypes configuration boolean to it. Therefore, we can’t use it to obtain the information we want about each item in the directory.

The good news is that we can write our own bindings for the function!

In Node, the readdirSync function can be called with two arguments: a string path and an options object. The function then returns an array of Dirent objects. In order to have a complete binding to the function, we need to understand how ReasonML types bind to JavaScript types.

We will create another module Fs, that will hold the bindings necessary for the readdirSync function.

Reason primitive types such as the string type compile to the exact same thing in JavaScript. Thus, when we write the binding to the readdirSync function, the path argument has the same type in Reason or JavaScript.

However, when it comes to the object type, Reason and JavaScript see them very differently. Therefore, we will have to write our own type bindings for the object types.

In our case, we have 2 objects we need to provide bindings to: the options object passed to readdirSync and its returned Dirent object.

Reason provides 3 ways of binding to JavaScript object types based on the use case:

1- If the object is used as a hashmap (it doesn’t have a specific fixed set of keys).

2- If the object has a specific fixed set of attributes and none of them is a method.

3- If the object has a specific fixed set of attributes and some of them are methods.

Since the options object only has 1 fixed attribute, which is the withFileTypes boolean, it clearly fits in the 2nd use case.

To understand the proper solution for the 2nd use case, we have to first get to know a Reason type called Record. In Reason, a Record is a lighter version of the JavaScript object that is immutable and has a fixed set of typed attributes. Here’s an example of a Record called person:

reasonml
1// creating the person Record2type person = { age: int, name: string };3
4// creating a value of type person5let seif = { age: 24, name: "Seif" };

Although Record values look like JavaScript objects, they don’t really compile to JavaScript objects.

Reason, however, provides an easy way to create object bindings out of Record types, by using [@bs.deriving abstract] followed by the Record :

reasonml
1[@bs.deriving abstract]2type readdirSyncOptions = {3  withFileTypes: bool,4};

We can even make withFileTypes attribute optional when creating an options object for readdirSync by prefixing the attribute with [@bs.optional] :

reasonml
1[@bs.deriving abstract]2type readdirSyncOptions = {3  [@bs.optional] withFileTypes: bool,4};

We can then create options this way:

reasonml
1// options with ~withFileTypes specified2let optionsA = readdirSyncOptions(~withFileTypes = true, ());3
4// options without ~withFileTypes5let optionsB = readdirSyncOptions();
Notice that when create options with withFileTypes specified, we need to pass () as the second parameter. This is required by Reason since withFileTypes is the only argument and it’s optional.

Recall that the Dirent object that is returned in an array by readdirSync has a name attribute of type string and a method isDirectory that does not receive any argument and returns a boolean. Thus, we are clearly in the 3rd use case.

Before writing a binding for the Dirent type, let’s first understand another less used Reason type: Js.t object. It looks like a Record with an extra . wrapped inside Js.t. Unlike the Record type, the Js.t object type in Reason compiles to a JavaScript object.

Here’s an example of a Reason Js.t object:

reasonml
1type person = Js.t({2  .3  name: string,4  [@bs.meth] greet: unit => unit,5});6
7let printPerson = (p:person) => {8  Js.log(p##name);9  p##greet();10}11

Reason provides a nicer sugar to write the same thing by getting rid of the Js.t prefix and placing the object keys between quotes:

reasonml
1type person = {2  .3  "name": string,4  [@bs.meth] "greet": unit => unit,5};6
7let printPerson = (p:person) => {8  Js.log(p##name);9  p##greet();10}

[@bs.meth] indicates that greet is a method.

Important: the . at the start of the Js.t object declaration is not a typo and is part of the syntax. Check out this for more details.

In our case, the dirent type is then expressed like this:

reasonml
1type dirent = {2  .3  "name": string,4  [@bs.meth] "isDirectory": unit => bool,5};

We have now bindings for the 2 objects involved in the readdirSync function. All that’s left is write our binding for the function itself. We saw earlier how to bind to a global value in Node by using [@bs.val] with the external syntax provided by Reason.

Well, binding to the method of node module such as fs is pretty similar, except that we use [@bs.module "module_name"] instead of [@bs.val]. This is what the binding to readdirSync looks like:

reasonml
1[@bs.module "fs"] external readdirSync: (string, readdirSyncOptions) => array(dirent) = "";

In the end, the Fs bindings module we created should look like the following:

reasonml
1*/* src/Fs.re */*2
3[@bs.deriving abstract]4type readdirSyncOptions = {5  [@bs.optional] withFileTypes: bool,6};7
8type dirent = {9  .10  "name": string,11  [@bs.meth] "isDirectory": unit => bool,12};13
14[@bs.module "fs"]  external readdirSync: (string, readdirSyncOptions) => array(dirent) = "";

Let’s then update our printDir function in Util to use our binding to readdirSync:

reasonml
1let printDir = (dirPath:string) => {2  let absolutePath = getAbsolutePath(dirPath);3  let options = Fs.readdirSyncOptions(~withFileTypes = true, ());4
5  Fs.readdirSync(absolutePath, options)6  |> Array.to_list7  |> List.iter((item:Fs.dirent) => {8    let prefix = item##isDirectory() ? {js|📁|js} : {js|📄|js};9    Js.log(prefix ++ " " ++ item##name);10  })11}

We notice 2 new things here:

  • The js annotation in {js|📁|js} : If you try to print Unicode characters such as 📁 in a normal string, it will not print properly. This is due to the fact that Reason’s strings are by default UTF-8 and don’t support UTF-16 characters. To rectify this, Reason offers the annotation to escape such Unicode characters.
  • The ++ operator used to concatenate strings.

Let’s then test printDir in our Demo.re file:

reasonml
1Util.printDir("../");2

When we run the compiled Demo.bs.js module with Node, we should see the following:

text
1📄 .bsb.lock2📄 .gitignore3📄 .merlin4📁 .vscode5📄 README.md6📄 bsconfig.json7📁 lib8📁 node_modules9📄 package.json10📁 src

So far, we’re only reading content at the top level of the directory. We want to also print the content of each directory recursively.

Before doing so, let’s create a function to pad text. This function will be useful to indent file/directory names.

reasonml
1let padText = (padding: int, text: string) => {2  String.make(padding, ' ') ++ text;3};

String.make: (int, string) => string as you can guess from its type signature, allows duplicating a string n times. Therefore, we pad string by adding a certain number of spaces before it.

Now, we need to modify our printDir function and make it recursive. We can then call it recursively with an incremented padding and directory path to print the content in sub-directories:

reasonml
1let rec printDir = (padding:int, dirPath:string) => {2  let absolutePath = getAbsolutePath(dirPath);3  let options = Fs.readdirSyncOptions(~withFileTypes = true, ());4
5  Fs.readdirSync(absolutePath, options)6  |> Array.to_list7  |> List.iter((item:Fs.dirent) => {8    if (!item##isDirectory()) {9      {js|📄|js} ++ " " ++ item##name10      |> padText(padding)11      |> Js.log;12    } else {13      {js|📁|js} ++ " " ++ item##name14      |> padText(padding)15      |> Js.log;16      printDir(17        padding + 1,18        absolutePath ++ "/" ++ item##name,19      );20    }21    })22};

Let’s then test printDir in our Demo.re file:

reasonml
1Util.printDir(0, "../");

When we run the compiled Demo.bs.js module with Node, we should see an extremely long list of items. This is due to the huge nested content of node_modules. We should allow users to avoid such cases by providing the user with an option to ignore certain directories.

Let’s first create a helper function to filter a list of directory items based on a name to ignore:

reasonml
1let filterItemsList = (~ignore:string, itemsList:list(Fs.dirent)) => {2  itemsList |> List.filter(item => {3    !item##isDirectory() || item##name != ignore;4  })5}

Notice how we made ignore a labeled argument by prefixing it with ~. In Reason, a labeled argument is similar to a normal argument except that we specify its name when calling the function. Using labeled arguments is advised as it enhances the readability of the function.

We will also modify the printDir function to:

  • Accept ignore and padding as labeled arguments
  • Use the filterItemsList helper function.
reasonml
1let rec printDir = (~padding:int, ~ignore:string, dirPath:string) => {2  let absolutePath = getAbsolutePath(dirPath);3  let options = Fs.readdirSyncOptions(~withFileTypes = true, ());34
5  Fs.readdirSync(absolutePath, options)6  |> Array.to_list7  |> filterItemsList(~ignore)8  |> List.iter((item:Fs.dirent) => {9    if (!item##isDirectory()) {10      {js|📄|js} ++ " " ++ item##name |> padText(padding) |> Js.log;11    } else if (item##name != ignore) {12      {js|📁|js} ++ " " ++ item##name |> padText(padding) |> Js.log;13      printDir(14        ~padding=padding + 1,15        ~ignore,16        absolutePath ++ "/" ++ item##name,17      );18    }19  });20};

Let’s then test printDir in our Demo.re file:

reasonml
1Util.printDir(~path="../", ~ignore="node_modules", ~padding=0);2

When we run the compiled Demo.bs.js module with Node, we should see the following output:

text
1.gitignore2📄 .merlin3📁 .vscode4 📄 settings.json5 📄 tasks.json6📄 README.md7📄 bsconfig.json8📁 lib9 📁 bs10  📄 .bsbuild11  📄 .bsdeps12  📄 .ninja_deps13  📄 .ninja_log14  📄 .sourcedirs.json15  📄 ReasonTree.cmi16  📄 ReasonTree.cmj17  📄 ReasonTree.cmt18  📄 ReasonTree.js19  📄 ReasonTree.mlmap20  📄 build.ninja21  📁 src22   📄 Demo-ReasonTree.cmi23   📄 Demo-ReasonTree.cmj24   📄 Demo-ReasonTree.cmt25   📄 Demo.mlast26   📄 Demo.mlast.d27   📄 Fs-ReasonTree.cmi28   📄 Fs-ReasonTree.cmj29   📄 Fs-ReasonTree.cmt30   📄 Fs.mlast31   📄 Fs.mlast.d32   📄 Util-ReasonTree.cmi33   📄 Util-ReasonTree.cmj34   📄 Util-ReasonTree.cmt35   📄 Util.mlast36   📄 Util.mlast.d37📄 package.json38📁 src39 📄 Demo.bs.js40 📄 Demo.re41 📄 Fs.bs.js42 📄 Fs.re43 📄 Util.bs.js44 📄 Util.re45📄 yarn.lock

Although the ignore argument is a nice feature, a user should also be able to print a directory’s content without specifying it. Therefore, we have to make ignore an optional argument.

Let’s start with the filterItemsList helper function:

reasonml
1let filterItemsList = (~ignore=?, itemsList:list(Fs.dirent)) => {2  switch(ignore) {3    | None => itemsList4    | Some(nameToIgnore) => itemsList |> List.filter(item => {5        !item##isDirectory() || item##name != nameToIgnore;6      });7  };8}

When making ignore an optional argument, the following happens:

  • If ignore is passed when calling the function, its value will be wrapped inside a Some(ignoreValue)
  • If it’s not passed when calling the function, its value will be None .

We can check whether ignore was specified or not and access its value through pattern matching. Pattern matching is pretty much a very powerful version of the normal JavaScript switch statement boosted with destructuring.

We should also reflect this in the printDir function:

reasonml
1let rec printDir = (~padding:int, ~ignore=?, dirPath:string) => {2  let absolutePath = getAbsolutePath(dirPath);3  let options = Fs.readdirSyncOptions(~withFileTypes = true, ());4
5  Fs.readdirSync(absolutePath, options)6  |> Array.to_list7  |> filterItemsList(~ignore=?ignore)8  |> List.iter((item:Fs.dirent) => {9    if (!item##isDirectory()) {10      {js|📄|js} ++ " " ++ item##name |> padText(padding) |> Js.log;11    } else {12      {js|📁|js} ++ " " ++ item##name |> padText(padding) |> Js.log;13
14      printDir(15        ~padding=padding + 1,16        ~ignore=?ignore,17        absolutePath ++ "/" ++ item##name,18      );19    }20  });21};
You probably noticed that we passed the ignore argument to the next printDir call with the syntax: =?ignore. This is necessary when passing down an optional parameter to another function call. You can read more about it in this section of Reason’s docs.