ReasonML: basic modules

[2017-12-20] dev, reasonml
(Ad, please don’t block)

Table of contents for this series of posts: “What is ReasonML?


In this blog post, we explore how modules work in ReasonML.

Installing the demo repository  

The demo repository for this blog post is available on GitHub: reasonml-demo-modules. To install it, download it and:

cd reasonml-demo-modules/
npm install

That’s all you need to do – no global installs necessary. If you want more support for ReasonML than just running its code, consult “Getting started with ReasonML”.

Your first ReasonML program  

This is where your first ReasonML program is located:

reasonml-demo-modules/
    src/
        HelloWorld.re

In ReasonML, each file whose name has the extension is .re is a module. The names of modules start with capital letters and are camel-cased. File names define the names of their modules, so they follow the same rules.

Programs are just modules that you run from a command line.

HelloWorld.re looks as follows:

/* HelloWorld.re */

let () = {
  print_string("Hello world!");
  print_newline()
};

This code may look a bit weird, so let me explain: We are executing the two lines inside the curly braces and assigning their result to the pattern (). That is, no new variables are created, but the pattern ensures that the result is (). The type of (), unit, is similar to void in C-style languages.

Note that we are not defining a function, we are immediately executing print_string() and print_newline().

To compile this code, you have two options (look at package.json for more scripts to run):

  • Compile everything, once: npm run build
  • Watch all files and incrementally compile only files that change: npm run watch

Therefore, our next step is (run in a separate terminal window or execute the last step in the background):

cd reasonml-demo-modules/
npm run watch

Sitting next to HelloWorld.re, there is now a file HelloWorld.bs.js. You can run this file as follows.

cd reasonml-demo-modules/
node src/HelloWorld.bs.js

Other versions of HelloWorld.re  

As an alternative to our approach (which is a common OCaml convention), we could have also simply put the two lines into the global scope:

/* HelloWorld.re */

print_string("Hello world!");
print_newline();

And we could have defined a function main() that we then call:

/* HelloWorld.re */

let main = () => {
  print_string("Hello world!");
  print_newline()
};
main();

Two simple modules  

Let’s continue with a module MathTools.re that is used by another module, Main.re:

reasonml-demo-modules/
    src/
        Main.re
        MathTools.re

Module MathTools looks like this:

/* MathTools.re */

let times = (x, y) => x * y;
let square = (x) => times(x, x);

Module Main looks like this:

/* Main.re */

let () = {
  print_string("Result: ");
  print_int(MathTools.square(3));
  print_newline()
};

As you can see, in ReasonML, you can use modules by simply mentioning their names. They are found anywhere within the current project.

Submodules  

You can also nest modules. So this works, too:

/* Main.re */

module MathTools = {
  let times = (x, y) => x * y;
  let square = (x) => times(x, x);
};

let () = {
  print_string("Result: ");
  print_int(MathTools.square(3));
  print_newline()
};

Externally, you can access MathTools via Main.MathTools.

Let’s nest further:

/* Main.re */

module Math = {
  module Tools = {
    let times = (x, y) => x * y;
    let square = (x) => times(x, x);
  };
};

let () = {
  print_string("Result: ");
  print_int(Math.Tools.square(3));
  print_newline()
};

Controlling how values are exported from modules  

By default, every module, type and value of a module is exported. If you want to hide some of these exports, you must use interfaces. Additionally, interfaces support abstract types (whose internals are hidden).

Interface files  

You can control how much you export via so-called interfaces. For a module defined by a file Foo.re, you put the interface in a file Foo.rei. For example:

/* MathTools.rei */

let times: (int, int) => int;
let square: (int) => int;

If, e.g., you omit times from the interface file, it won’t be exported.

The interface of a module is also called its signature.

If an interface file exists, then docblock comments must be put there. Otherwise, you put them into the .re file.

Thankfully, we don’t have to write interfaces by hand, we can generate them from modules. How is described in the BuckleScript documentation. For MathTools.rei, I did it via:

bsc -bs-re-out lib/bs/src/MathTools-ReasonmlDemoModules.cmi

Defining interfaces for submodules  

Let’s assume, MathTools doesn’t reside in its own file, but exists as a submodule:

module MathTools = {
  let times = (x, y) => x * y;
  let square = (x) => times(x, x);
};

How do we define an interface for this module? We have two options.

First, we can define and name an interface via module type:

module type MathToolsInterface = {
  let times: (int, int) => int;
  let square: (int) => int;
};

That interface becomes the type of module MathTools:

module MathTools: MathToolsInterface = {
  ···
};

Second, we can also inline the interface:

module MathTools: {
  let times: (int, int) => int;
  let square: (int) => int;
} = {
  ···
};

Abstract types: hiding internals  

You can use interfaces to hide the details of types. Let’s start with a module Log.re that lets you put strings “into” logs. It implements logs via strings and completely exposes this implementation detail by using strings directly:

/* Log.re */

let make = () => "";
let logStr = (str: string, log: string) => log ++ str ++ "\n";

let print = (log: string) => print_string(log);

From this code, it isn’t clear that make() and logStr() actually return logs.

This is how you use Log. Note how convenient the pipe operator (|>) is in this case:

/* LogMain.re */

let () = Log.make()
  |> Log.logStr("Hello")
  |> Log.logStr("everyone")
  |> Log.print;

/* Output:
Hello
everyone
*/

The first step in improving Log is by introducing a type for logs. The convention, borrowed from OCaml, is to use the name t for the main type supported by a module. For example: Bytes.t

/* Log.re */

type t = string; /* A */

let make = (): t => "";
let logStr = (str: string, log: t): t => log ++ str ++ "\n";

let print = (log: t) => print_string(log);

In line A we have defined t to be simply an alias for strings. Aliases are convenient in that you can start simple and add more features later. However, the alias forces us to annotate the results of make() and logStr() (which would otherwise have the return type string).

The full interface file looks as follows.

/* Log.rei */

type t = string; /* A */
let make: (unit) => t;
let logStr: (string, t) => t;
let print: (t) => unit;

We can replace line A with the following code and t becomes abstract – its details are hidden. That means that we can easily change our minds in the future and, e.g., implement it via an array.

type t;

Conveniently, we don’t have to change LogMain.re, it still works with the new module.

Importing values from modules  

There are several ways in which you can import values from modules.

Importing via qualified names  

We have already seen that you can automatically import a value exported by a module if you qualify the value’s name with the module’s name. For example, in the following code we import make, logStr and print from module Log:

let () = Log.make()
  |> Log.logStr("Hello")
  |> Log.logStr("everyone")
  |> Log.print;

Opening modules globally  

You can omit the qualifier “Log.” if you open Log “globally” (within the scope of the current module):

open Log;

let () = make()
  |> logStr("Hello")
  |> logStr("everyone")
  |> print;

To avoid name clashes, this operation is not used very often. Most modules, such as List, are used via qualifications: List.length(), List.map(), etc.

Global opening can also be used to opt into different implementations for standard modules. For example, module Foo might have a submodule List. Then open Foo; will override the standard List module.

Opening modules locally  

We can minimize the risk of name clashes, while still getting the convenience of an open module, by opening Log locally. We do that by prefixing a parenthesized expression with Log. (i.e., we are qualifying that expression). For example:

let () = Log.(
  make()
    |> logStr("Hello")
    |> logStr("everyone")
    |> print
);

Redefining operators  

Conveniently, operators are also just functions in ReasonML. That enables us to temporarily override built-in operators. For example, we may not like having to use operators with dots for floating point math:

let dist = (x, y) =>
  sqrt((x *. x) +. (y *. y));

Then we can override the nicer int operators via a module FloatOps:

module FloatOps = {
  let (+) = (+.);
  let (*) = (*.);
};
let dist = (x, y) =>
  FloatOps.(
    sqrt((x * x) + (y * y))
  );

Whether or not you actually should do this in production code is debatable.

Including modules  

Another way of importing a module is to include it. Then all of its exports are added to the exports of the current module. This is similar to inheritance between classes in object-oriented programming.

In the following example, module LogWithDate is an extension of module Log. It has the new function logStrWithDate(), in addition to all functions of Log.

/* LogWithDateMain.re */

module LogWithDate = {
  include Log;
  let logStrWithDate = (str: string, log: t) => {
    let dateStr = Js.Date.toISOString(Js.Date.make());
    logStr("[" ++ dateStr ++ "] " ++ str, log);
  };
};
let () = LogWithDate.(
  make()
    |> logStrWithDate("Hello")
    |> logStrWithDate("everyone")
    |> print
);

Js.Date comes from BuckleScript’s standard library and is not explained here.

You can include as many modules as you want, not just one.

Including interfaces  

Interfaces are included as follows (InterfaceB extends InterfaceA):

module type InterfaceA = {
  ···
};
module type InterfaceB = {
  include InterfaceA;
  ···
}

Similarly to modules, you can include more than one interface.

Let’s create an interface for module LogWithDate. Alas, we can’t include the interface of module Log by name, because it doesn’t have one. We can, however, refer to it indirectly, via its module (line A):

module type LogWithDateInterface = {
  include (module type of Log); /* A */
  let logStrWithDate: (t, t) => t;
};
module LogWithDate: LogWithDateInterface = {
  include Log;
  ···
};

Renaming imports  

You can’t really rename imports, but you can alias them.

This is how you alias modules:

module L = List;

This is how you alias values inside modules:

let m = List.map;

Namespacing modules  

In large projects, ReasonML’s way of identifying modules can become problematic. Since it has a single global module namespace, there can easily be name clashes. Say, two modules called Util in different directories.

One technique is to use namespace modules. Take, for example, the following project:

proj/
    foo/
        NamespaceA.re
        NamespaceA_Misc.re
        NamespaceA_Util.re
    bar/
        baz/
            NamespaceB.re
            NamespaceB_Extra.re
            NamespaceB_Tools.re
            NamespaceB_Util.re

There are two modules Util in this project whose names are only distinct because they were prefixed with NamespaceA_ and NamespaceB_, respectively:

proj/foo/NamespaceA_Util.re
proj/bar/baz/NamespaceB_Util.re

To make naming less unwieldy, there is one namespace module per namespace. The first one looks like this:

/* NamespaceA.re */
module Misc = NamespaceA_Misc;
module Util = NamespaceA_Util;

NamespaceA is used as follows:

/* Program.re */

open NamespaceA;

let x = Util.func();

The global open lets us use Util without a prefix.

There are two more use cases for this technique:

  • You can override modules with it, even modules from the standard library. For example, NamespaceA.re could contain a custom List implementation, which would override the built-in List module inside Program.re:
    module List = NamespaceA_List;
    
  • You can create nested modules while keeping submodules in separate files. For example, in addition to opening NamespaceA, you can also access Util via NamespaceA.Util, because it is nested inside NamespaceA. Of course, NamespaceA_Util works, too, but is discouraged, because it is an implementation detail.

The latter technique is used by BuckleScript for Js.Date, Js.Promise, etc., in file js.ml (which is in OCaml syntax):

···
module Date = Js_date
···
module Promise = Js_promise
···
module Console = Js_console

Namespace modules in OCaml  

Namespace modules are used extensively in OCaml at Jane Street. They call them packed modules, but I prefer the name namespace modules, because it doesn’t clash with the npm term package.

Source of this section: “Better namespaces through module aliases” by Yaron Minsky for Jane Street Tech Blog.

Exploring the standard library  

There are two big caveats attached to ReasonML’s standard library:

  • It is currently work in progress.
  • Its naming style for values inside modules will change from snake case (foo_bar and Foo_bar) to camel case (fooBar and FooBar).
  • At the moment, much functionality is still missing.

API docs  

ReasonML’s standard library is split: most of the core ReasonML API works on both native and JavaScript (via BuckleScript). If you compile to JavaScript, you need to use BuckleScript’s API in two cases:

  • Functionality that is completely missing from ReasonML’s API. Examples include support for dates, which you get via BuckleScript’s Js.Date.
  • ReasonML API functionality that is not supported by BuckleScript. Examples include modules Str (due to JavaScript’s strings being different from ReasonML’s native ones) and Unix (with native APIs).

This is the documentation for the two APIs:

Module Pervasives  

Module Pervasives contains the core standard library and is always automatically opened for each module. It contains functionality such as the operators ==, +, |> and functions such as print_string() and string_of_int().

If something in this module is ever overridden, you can still access it explicitly via, e.g., Pervasives.(+).

If there is a file Pervasives.re in your project, it overrides the built-in module and is opened instead.

Standard functions with labeled parameters  

The following modules exist in two versions: an older one, where functions have only positional parameters and a newer one, where functions also have labeled parameters.

  • Array, ArrayLabels
  • Bytes, BytesLabels
  • List, ListLabels
  • String, StringLabels

As an example, consider:

List.map: ('a => 'b, list('a)) => list('b)
ListLabels.map: (~f: 'a => 'b, list('a)) => list('b)

Two more modules provide labeled functions:

  • Module StdLabels has the submodules Array, Bytes, List, String, which are aliases to ArrayLabels etc. In your modules, you can open StdLabels to get a labeled version of List by default.
  • Module MoreLabels has three submodules with labeled functions: Hashtbl, Map and Set.

Installing libraries  

For now, JavaScript is the preferred platform for ReasonML. Therefore, the preferred way of installing libraries is via npm. This works as follows. As an example, assume we want to install the BuckleScript bindings for Jest (which include Jest itself). The relevant npm package is called bs-jest.

First, we need to install the package. Inside package.json, you have:

{
  "dependencies": {
    "bs-jest": "^0.1.5"
  },
  ···
}

Second, we need to add the package to bsconfig.json:

{
  "bs-dependencies": [
    "bs-jest"
  ],
  ···
}

Afterwards, we can use module Jest with Jest.describe() etc.

More information on installing libraries:

Further reading