How to get a taste of ReasonML by building something useful

Seif Ghezala's photo
Seif Ghezala
Updated 2023-11-13 · 19 min
Table of contents

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

Setting up the editor

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
Enabling the Format On Save option on VSCode

Bootstrapping the project with bsb

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

npm 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 :

bsb -init reason-tree -theme basic-reason

"Hello 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:

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

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

npm 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.

src/
Demo.re
Demo.bs.js

We can go ahead and run it with node:

node src/Demo.bs.js

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

Hello, BuckleScript and Reason!

Our first function

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:

let getAbsolutePath = (path:string) => {
let absolutePath = "some/absolute/path";
absolutePath;
}
Js.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.

Our first module

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.

src/
Demo.re
Demo.bs.js
Util.re
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:

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

Reading the actual absolute 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:

// we require the path module
const path = require('path');
// let somePath be a path given by the user
const somePath = './';
// __dirname is the current path
let 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:

(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:

[@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:

[@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:

[@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:

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

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

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

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

Reading & printing the directory’s content

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.

let printDir = (dirPath:string) => {
let absolutePath = getAbsolutePath(dirPath);
let contentArray = Node.Fs.readdirSync(absolutePath);
let contentList = Array.to_list(contentArray);
List.iter((item:string) => {
Js.log("name: " ++ item);
}, contentList)
}

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:

dirPath -> 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:

let printDir = (dirPath:string) => {
dirPath
|> getAbsolutePath
|> Node.Fs.readdirSync
|> Array.to_list
|> List.iter((item:string) => {
Js.log("name: " ++ item)
})
}

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!

Writing bindings to the readdirSync 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.

Reason’s interoperability with JavaScript objects

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:

// creating the person Record
type person = { age: int, name: string };
// creating a value of type person
let 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 :

[@bs.deriving abstract]
type readdirSyncOptions = {
withFileTypes: bool,
};

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

[@bs.deriving abstract]
type readdirSyncOptions = {
[@bs.optional] withFileTypes: bool,
};

We can then create options this way:

// options with ~withFileTypes specified
let optionsA = readdirSyncOptions(~withFileTypes = true, ());
// options without ~withFileTypes
let 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:

type person = Js.t({
.
name: string,
[@bs.meth] greet: unit => unit,
});
let printPerson = (p:person) => {
Js.log(p##name);
p##greet();
}

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:

type person = {
.
"name": string,
[@bs.meth] "greet": unit => unit,
};
let printPerson = (p:person) => {
Js.log(p##name);
p##greet();
}

[@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:

type dirent = {
.
"name": string,
[@bs.meth] "isDirectory": unit => bool,
};

Binding of the readdirSync function

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:

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

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

*/* src/Fs.re */*
[@bs.deriving abstract]
type readdirSyncOptions = {
[@bs.optional] withFileTypes: bool,
};
type dirent = {
.
"name": string,
[@bs.meth] "isDirectory": unit => bool,
};
[@bs.module "fs"] external readdirSync: (string, readdirSyncOptions) => array(dirent) = "";

Updating the printDir function

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

let printDir = (dirPath:string) => {
let absolutePath = getAbsolutePath(dirPath);
let options = Fs.readdirSyncOptions(~withFileTypes = true, ());
Fs.readdirSync(absolutePath, options)
|> Array.to_list
|> List.iter((item:Fs.dirent) => {
let prefix = item##isDirectory() ? {js|📁|js} : {js|📄|js};
Js.log(prefix ++ " " ++ item##name);
})
}

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:

Util.printDir("../");

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

📄 .bsb.lock
📄 .gitignore
📄 .merlin
📁 .vscode
📄 README.md
📄 bsconfig.json
📁 lib
📁 node_modules
📄 package.json
📁 src

Reading content recursively

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.

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

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:

let rec printDir = (padding:int, dirPath:string) => {
let absolutePath = getAbsolutePath(dirPath);
let options = Fs.readdirSyncOptions(~withFileTypes = true, ());
Fs.readdirSync(absolutePath, options)
|> Array.to_list
|> List.iter((item:Fs.dirent) => {
if (!item##isDirectory()) {
{js|📄|js} ++ " " ++ item##name
|> padText(padding)
|> Js.log;
} else {
{js|📁|js} ++ " " ++ item##name
|> padText(padding)
|> Js.log;
printDir(
padding + 1,
absolutePath ++ "/" ++ item##name,
);
}
})
};

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

Util.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.

Adding the ignore option

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

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

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.
let rec printDir = (~padding:int, ~ignore:string, dirPath:string) => {
let absolutePath = getAbsolutePath(dirPath);
let options = Fs.readdirSyncOptions(~withFileTypes = true, ());3
Fs.readdirSync(absolutePath, options)
|> Array.to_list
|> filterItemsList(~ignore)
|> List.iter((item:Fs.dirent) => {
if (!item##isDirectory()) {
{js|📄|js} ++ " " ++ item##name |> padText(padding) |> Js.log;
} else if (item##name != ignore) {
{js|📁|js} ++ " " ++ item##name |> padText(padding) |> Js.log;
printDir(
~padding=padding + 1,
~ignore,
absolutePath ++ "/" ++ item##name,
);
}
});
};

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

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

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

.gitignore
📄 .merlin
📁 .vscode
📄 settings.json
📄 tasks.json
📄 README.md
📄 bsconfig.json
📁 lib
📁 bs
📄 .bsbuild
📄 .bsdeps
📄 .ninja_deps
📄 .ninja_log
📄 .sourcedirs.json
📄 ReasonTree.cmi
📄 ReasonTree.cmj
📄 ReasonTree.cmt
📄 ReasonTree.js
📄 ReasonTree.mlmap
📄 build.ninja
📁 src
📄 Demo-ReasonTree.cmi
📄 Demo-ReasonTree.cmj
📄 Demo-ReasonTree.cmt
📄 Demo.mlast
📄 Demo.mlast.d
📄 Fs-ReasonTree.cmi
📄 Fs-ReasonTree.cmj
📄 Fs-ReasonTree.cmt
📄 Fs.mlast
📄 Fs.mlast.d
📄 Util-ReasonTree.cmi
📄 Util-ReasonTree.cmj
📄 Util-ReasonTree.cmt
📄 Util.mlast
📄 Util.mlast.d
📄 package.json
📁 src
📄 Demo.bs.js
📄 Demo.re
📄 Fs.bs.js
📄 Fs.re
📄 Util.bs.js
📄 Util.re
📄 yarn.lock

Making ignore an optional argument

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:

let filterItemsList = (~ignore=?, itemsList:list(Fs.dirent)) => {
switch(ignore) {
| None => itemsList
| Some(nameToIgnore) => itemsList |> List.filter(item => {
!item##isDirectory() || item##name != nameToIgnore;
});
};
}

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:

let rec printDir = (~padding:int, ~ignore=?, dirPath:string) => {
let absolutePath = getAbsolutePath(dirPath);
let options = Fs.readdirSyncOptions(~withFileTypes = true, ());
Fs.readdirSync(absolutePath, options)
|> Array.to_list
|> filterItemsList(~ignore=?ignore)
|> List.iter((item:Fs.dirent) => {
if (!item##isDirectory()) {
{js|📄|js} ++ " " ++ item##name |> padText(padding) |> Js.log;
} else {
{js|📁|js} ++ " " ++ item##name |> padText(padding) |> Js.log;
printDir(
~padding=padding + 1,
~ignore=?ignore,
absolutePath ++ "/" ++ item##name,
);
}
});
};
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.


Recent articles