Table of contents for this series of posts: “What is ReasonML?”
In this blog post, we explore how modules work in ReasonML.
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”.
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):
npm run build
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
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();
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.
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()
};
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).
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
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;
} = {
···
};
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.
There are several ways in which you can import values from modules.
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;
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.
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
);
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.
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.
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;
···
};
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;
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:
NamespaceA.re
could contain a custom List
implementation, which would override the built-in List
module inside Program.re
:module List = NamespaceA_List;
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 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.
There are two big caveats attached to ReasonML’s standard library:
foo_bar
and Foo_bar
) to camel case (fooBar
and FooBar
).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:
Js.Date
.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:
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.
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:
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.MoreLabels
has three submodules with labeled functions: Hashtbl
, Map
and Set
.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:
reason
, reasonml
, bucklescript