Explaining undocumented syntax in ReasonML
I've been writing a lot of ReasonML recently and if you try to take advantage of some of the more 'esoteric' features of the language then it can be quite difficult to find any information it, especially about the ReasonML syntax as opposed to the OCaml syntax. I'm going to try and clear it all up here.
Just a quick note - If you are confused about how to convert some OCaml syntax to ReasonML syntax, use the online playground and copy the OCaml code into the bottom section. It will automatically be converted into formatted ReasonML code at the top.
A lot of the code described below also breaks syntax highlighting in the vscode plugin!
Creating modules in various ways
There are a few examples of creating modules in the documentation, but there are a lot of ways to create modules that interact with first class modules (below) that you should be aware of.
If we have this module type:
module type Appendable = {
type t('a);
let append: (t('a), t('a)) => t('a);
};
We can define a 'Make' module (a functor) like this:
module MakeAppendable = (XI: Appendable) => {
type t('a) = XI.t('a);
let append = XI.append;
};
Which can be used like this:
// 1: Separate module
module ListAppendable = {
type t('a) = list('a);
let append = List.append;
}
module ListAppend = MakeAppendable(ListAppendable);
// 2: Inline module in creating module
module ListAppend = MakeAppendable({
type t('a) = list('a);
let append = List.append;
})
We could also just replace our original functor module with this:
module IncludeMakeAppendable = (XI: Appendable) => {
include XI;
}
Or just skip the functor altogether:
module ListAppend: Appendable = {
type t('a) = list('a);
let append = List.append;
}
If you can skip the intermediary, why would you ever use it? Well, there
are good causes to use it when you have a separation between what
functions need to be defined by the user and what functions are actually
exposed by the module. For example, if we want to add another member to
Appendable
which takes two lists and appends them in reverse order,
the user shouldn't need to define this function because it can be done
using their append
implementation. In this case, we can have two
module types:
Appendable
which defines a module that can append two items.RAppanedable
which can also append items in reverse order
This can be defined like so:
module type RAppendable = {
include Appendable;
let rappend: (t('a), t('a)) => t('a);
};
module MakeRAppendable = (XIR: Appendable) => {
include XIR;
let rappend = (a, b) => XIR.append(b, a);
}
Now we have our functor which only requires the user to define the
things in Appendable
, but exposes a module type of RAppendable
.
This can also be used to define one module type which presents the
'interface' to a module but let the underlying implementation act on a
parametrized type which has a different number of type arguments. For
example, in the
Rationale Monad
library, the MakeGeneral
functor defines a lot of the behaviour of the
final module, but there are also the functors Basic
and Basic2
(which could be extended to Basic3
if you want it, or Basic4
, etc)
which define the basic building blocks that have to be implemented by
the user.
First class modules
This is well documented in the OCaml documentation and in other places:
https://v1.realworldocaml.org/v1/en/html/first-class-modules.html
But there isn't anything in the ReasonML documentation about them. Basically, you can scope the definition of a new parametrised module inside any valid scope block, including inside of a module or inside of a function.
If we have this module type and a corresponding 'create' module:
module type Appendable = {
type t('a);
let append: (t('a), t('a)) => t('a);
};
module MakeAppendable = (XI: Appendable) => {
type t('a) = XI.t('a);
let append = XI.append;
};
A toy example for doing this inside a function:
let concat_lists = (a, b) => {
module C =
MakeAppendable({
type t('a) = list('a);
let append = List.append;
});
C.append(a, b);
};
Binding a module to a variable:
let create_concat: (module Appendable) = {
(module {
type t('a) = array('a);
let append = Array.append;
})
}
Note that the type has to be annotated ((module Appendable)
) - if this
is not annotated, you will get a compilation error.
These can also be passed to functions but there are restrictions on how you use them, such as the fact that you cannot return polymorphic types inside modules using this syntax - see below.
Creative uses of first class modules
Using the above patterns, we can create functions which return modules which have values bound inside them.
Say we have a simple module type that returns the max of two 'things':
module type MaxVal = {
type t;
let max: (t, t) => t;
};
However we also want to possibly compare it to a third value - for example, if we are using integers and our 'max' value should never be below 100, we can use it like so:
module MaxInt = {
type t = int;
let max = max;
};
let max_min_100 = (a, b) => {
max(100, MaxInt.max(a, b));
};
We could also use a first class module to 'bind' this behaviour to a
module - ie, a function that takes a 'minimum' value and will return a
module where max
will always return at least 100. To do this we have
to use a locally abstract type:
let const_addable = (type at, x: at): (module MaxVal with type t = at) => {
(module
{
type t = at;
let max = (a, b) => max(max(a, b), x);
});
};
Which we use like so:
let const_max = (type at, x: at): (module MaxVal with type t = at) => {
(module
{
type t = at;
let max = (a, b) => max(max(a, b), x);
});
};
let max_min_100 = (a, b) => {
module M = (val const_max(100): MaxVal with type t = int);
// or:
// let m = const_max(100);
// module M = (val m: MaxVal with type t = int);
M.max(a, b);
};
The (val ...)
syntax calls the function and binds the return value to
a local module. This is a bit confusing, but it is required for the
compiler to not complain about invalid syntax.
This is complete overkill for such a simple problem, but bearing in mind that you can still use that module as a parameter to another functor and create very modules with very specific behaviour inside your function.
Note that you cannot return polymorphic types like this:
// Error
let create_concat: (module Appendable with type t('a) = array('a)) = {
Passing modules to functions
Say you want to define a module type which defines a module that can convert from 64 bit integers and back:
module type BigIntConvertable = {
type t;
let of_big_int: Int64.t => t;
let to_big_int: t => Int64.t;
};
If you do this using the 'simple' module creation as described above:
module BigIntConvertableInt: BigIntConvertable = {
type t = int;
let of_big_int = Int64.to_int;
let to_big_int = Int64.of_int;
}
We can obviously use this module directly, but for an example we are going to try and define a function which takes the module as an argument and uses a function in it.
Defining the function
This is our first naive try:
let convert_with_module = (module R: BigIntConvertable, value) => {
R.to_big_int(value);
};
And you try to compile it
18 ┆
19 ┆ let convert_with_module = (module R: BigIntConvertable, value) => {
20 ┆ R.to_big_int(value);
21 ┆ };
22 ┆
This allows type R.t to "escape" its scope.
This type: 'a
Expecting: R.t
<... more error message ...>
Because we don't know the type of value
or R.t
, the type checker
does not know what to do.
We will go into more detail later, but for now let's just explicitly tag 'value' as an int:
let convert_with_module = (module R: BigIntConvertable, value: int) => {
R.to_big_int(value);
};
Then we get this
18 ┆
19 ┆ let convert_with_module = (module R: BigIntConvertable, value: int) => {
20 ┆ R.to_big_int(value);
21 ┆ };
22 ┆
This type doesn't match what is expected.
This type: int
Expecting: R.t
We don't explicitly say what R.t is, and the type checker cannot infer what it is supposed to be.
This can be solved by explicitly specifying what R.t
is:
let convert_with_module =
(module R: BigIntConvertable with type t = int, value: int) => {
R.to_big_int(value);
};
This compiles fine!
Using it
Ok, now we can pass a module to this function. Let's try using the module we defined earlier:
convert_with_module((module BigIntConvertableInt), 2);
Then we get another error:
32 ┆
33 ┆ convert_with_module((module BigIntConvertableInt), 2);
34 ┆
This type doesn't match what is expected.
This type: int
Expecting: BigIntConvertableInt.t
More confusing type errors. We can solve this by annotating the module at the point we call the function as well:
convert_with_module(
(module BigIntConvertableInt): (module BigIntConvertable with type t = int),
2,
);
This will give a similar error:
32 ┆
33 ┆ convert_with_module(
34 ┆ (module BigIntConvertableInt): (module BigIntConvertable with type t = int),
35 ┆ 2,
36 ┆ );
37 ┆
This type doesn't match what is expected.
This type:
ML: (module BigIntConvertable with type t
Equals
ML: BigIntConvertableInt.t)
Expecting:
ML: (module BigIntConvertable with type t
Equals
ML: int)
The contradicting part:
The type: BigIntConvertableInt.t
Contradicts: int
Even though we are explicitly specifying the type, it won't accept it.
Trying to annotate the use of the module will not work in this situation - you need to annotate the definition of the module with the correct type:
module BigIntConvertableInt: BigIntConvertable with type t = int = {
...
}
Then it will all work as expected.
Fixing our original function
Now that we are correctly specifying the types when we call the function, we can stop the function only working with integers with a locally abstract type:
let convert_with_module =
(type ct, module R: BigIntConvertable with type t = ct, value: ct) => {
R.to_big_int(value);
};
Now we can use whatever we want:
convert_with_module((module BigIntConvertableInt), 2);
convert_with_module(
(module
{
type t = float;
let to_big_int = Int64.of_float;
let of_big_int = Int64.to_float;
}): (module BigIntConvertable with type t = float),
2.0,
);
// etc
Using first class modules
We can also do this using a function that returns a first class module:
let int_converter: module BigIntConvertable with type t = int =
(module
{
type t = int;
let of_big_int = Int64.to_int;
let to_big_int = Int64.of_int;
});
convert_with_module(
int_converter,
2,
);
Though whether this is really better or not is up to you.
Locally abstract types with GADTs
There is a lot to say about GADTs (= Generalised Algebraic Data Types) which is out of scope of this post (see here for a good explanation) but using them properly requires some special syntax.
As an example, let's say we want to wrap numbers into a type:
type number('a) =
| Int(int): number(int)
| Int64(Int64.t): number(Int64.t)
| Float(float): number(float)
| Char(char): number(char);
let int_val = Int(32);
let float_val = Float(32.0);
Then we want to define an operator which will add the values wrapped inside these types, and we only want to add the same types. We can do this using locally abstract types, mentioned before:
let (+?): type tt. (number(tt), number(tt)) => number(tt) =
(a, b) => {
switch (a, b) {
| (Int(x), Int(y)) => Int(x + y)
| (Int64(x), Int64(y)) => Int64(Numeric.Int64Operations.(x + y))
| (Char(x), Char(y)) => Char(Char.(chr(code(x) + code(y))))
| (Float(x), Float(y)) => Float(x +. y)
};
};
let int_1 = Int(32);
let int_2 = Int(32);
let int_3 = int_1 +? int_2;
The syntax says that for all possible input types tt
(which has
already been restricted to an int, 64 bit int, float, or char in the
type definition), then calling that function will return the same type.
Pretty useless, but what if we then want to extend this to do something we did before - converting to and from a 64 bit integer?
let to_big_int: type tt. number(tt) => Int64.t =
fun
| Int(y) => Int64.of_int(y)
| Int64(i) => i
| Float(f) => Int64.of_float(f)
| Char(a) => Int64.of_int(Char.code(a));
Here, to_big_int
says that for all possible variants of number
calling this function will return a 64 bit integer.
This is a bit more complicated for converting from a big integer because we need to specify what type we want to convert it to:
let of_big_int: type tt. (number(tt), Int64.t) => tt =
fun
| Int(_) => Int64.to_int
| Int64(_) => id
| Float(_) => Int64.to_float
| Char(_) => (c => Int64.to_int(c) |> Char.chr);
let wrapped_int = (Int(0), 32423423L);
let wrapped_float = (Float(0.0), 32423423L);
// etc.
Note that this is just a toy example to show off the syntax, GADTs are mostly useful when the types are defined recursively.