Modules are groupings of functions and type definitions that allow us to organize and reuse code in complex programs. Every file in ReScript is its own module, but we can also define our own modules inside of files.
Modules and module signatures are very important for writing software in ReScript, because they serve several key purposes:
Namespacing – Modules allow the programmer to differentiate between functions/bindings with the same name declared in different places (e.g., Belt.Array.map, Js.Array2.map, Js.Array.map). This allows the programmer to use and write libraries without worrying about collisions between common function names.
Abstraction – Module signatures allow the programmer to expose a particular API without revealing its underlying details. For example, we could hide the internal types and helper functions a data structure uses. In a typical object-oriented language, this could be accomplished by using interfaces and private methods; in ReScript this role is fulfilled by module signatures.
Code reuse – Something defined in one module can be reused in other modules to reduce code duplication. In object-oriented languages, one would typically go about this via inheritance or composition. Composition is still possible in ReScript, and we also have access to functors, which are like functions that operate on modules instead of values.
Files as Modules
Every ReScript file is implicitly a module, and the module’s name is the capitalized form of the file’s name. The file Hello.res will correspond to a module named Hello.
Module names are always capitalized, so it’s a good idea to capitalize ReScript files’ names to match their module names. For example, use Hello.res instead of hello.res, since the module name will always be Hello.
All files are treated as modules in the same scope, and folders are ignored. This means that no two .res files in a ReScript project can share the same name each other, even if they are in different directories.
Defining Modules
Modules can also be defined inside of files. Module definitions look similar to let bindings, except they use the
module keyword. Module
names must be capitalized, and the contents of the module are wrapped in curly braces:
module MyModule = {
type myType = int
let hello = () => Js.log("hello")
}
Anything that we would put in a file can go into a module definition: this includes type definitions, let bindings, function definitions, and other module definitions.
Nested
modules are a common pattern for organizing complex APIs – this can be seen in the standard library. For example, the function
Belt.Array.get is inside the
Array module, which is inside the
Belt module. Conceptually, it can be modeled as such:
module Belt = {
module Array = {
let get = ...
}
}
Module definitions can have the same name as long as they are in different scopes (see
Belt.Array and
Js.Array). However, just like how file names need to be unique, modules in the same scope have to have unique
names. The following is not allowed:
Compiler output:
Multiple definition of the module name MyModule.
Names must be unique in a given structure or signature.
Using Modules
We can reference the functions and
values defined inside a module by using the same notation as accessing fields from records:
module MyModule = {
type myType = int
let myValue = 1
let hello = () => Js.log("hello")
}
let x = MyModule.myValue
MyModule.hello()
The
types from a module can be referenced and used as part of signatures and new type definitions:
type newType1 = MyModule.myType
type newType2 = array<MyModule.myType>
let y : MyModule.myType = 1
This is also how we would reference the contents of other files. For example, say we had a file called
Hello.res with the following contents:
let helloWorld = () => Js.log("hello, world")
We can use it in another file
Myfile.res:
Compiling the project and running
Myfile.bs.js prints the following in console:
When accessing something inside a nested
module, we have to list each enclosing module, as we’ve already seen with examples involving the standard library:
module MyModule = {
module MyNestedModule = {
let x = "blah"
}
}
MyModule.MyNestedModule.x->Js.log
Opening Modules
Having to type out the fully qualified names of every function we use can be tiresome, especially if we’re working with a lot of nested modules.
As seen in previous chapters, we can open a module to make its contents visible in the current scope. This lets us avoid having to write the module’s name when accessing its contents. We can see this in action using the previous nested module
example:
module MyModule = {
module MyNestedModule = {
let x = "blah"
}
}
MyModule.MyNestedModule.x->Js.log
open MyModule
MyNestedModule.x->Js.log
open MyModule.MyNestedModule
x->Js.log
Opening a module is not the same as importing a declaration in another language. All modules not hidden by a signature are visible from other modules, so you’re not required to import anything. Opening a module only makes the module’s contents easier to access by making their names shorter. Additionally, while other languages may only allow imports at the top level or at the beginning of each file, ReScript allows us to open modules in any scope.
Be careful to avoid unintentional shadowing when opening modules – opening a module will cause the module’s bindings to shadow any bindings inside the current scope. Usually, this mistake is not a big deal – if the shadowed binding is a different type, then the code will not compile. However, if the shadowed binding is the same type then this may go unnoticed.
In the following code snippet, opening
MyModule.MyNestedModule causes its binding for
x to shadow the binding we declared earlier, resulting in an unexpected value being printed:
open MyModule.MyNestedModule
x->Js.log
Thankfully, the compiler emits a
warning when opening a module shadows an existing binding. Using
open! instead of
open will silence the warning, but be careful and deliberate in its usage.
This open statement shadows the value identifier x (which is later used).
Shadowing in the Standard Library
There is one common case where unintended shadowing can trip up newcomers. As described in an earlier chapter, opening the Belt standard library changes the behavior of array indexing. Therefore, files that have open Belt at the top and files that do not will have different behaviors when indexing arrays.
In the first example, everything works fine. We don’t open any modules and we fully qualify
Belt.Array:let double = arr => {
arr->Belt.Array.map(x => x * 2)
}
let triple = arr => {
arr->Belt.Array.map(x => x * 3)
}
let myArr = [1, 2, 3]->double->triple
Js.log(myArr[0] === 6)
Opening
Belt at the top of the file makes things more concise, allowing us to write
Array.map instead of
Belt.Array.map. Unfortunately, the behavior and type signature of array indexing have changed due to shadowing, and our code no longer
compiles:
let double = arr => {
arr->Array.map(x => x * 2)
}
let triple = arr => {
arr->Array.map(x => x * 3)
}
let myArr = [1, 2, 3]->double->triple
Js.log(myArr[0] === 6)
Compiler output:
This has type: int
Somewhere wanted: option<int>
If we open
Belt.Array only in the functions where we need it, we can eliminate the clunky prefixes without any risk of shadowing or unintended behavior:
let double = arr => {
open Belt.Array
arr->map(x => x * 2)
}
let triple = arr => {
open Belt.Array
arr->map(x => x * 3)
}
let myArr = [1, 2, 3]->double->triple
Js.log(myArr[0] === 6)
Although the last example is a bit contrived since each function only uses Belt.Array.map once, I hope you see my point. We don’t have to open every module we might use at the top of the file – opening modules in a nested scope can help avoid unintentional shadowing.
Destructuring a Module
If we only want to access a few functions/bindings in a module, one alternative to opening a module is destructuring. Destructuring a module is similar to destructuring a record, allowing us to easily access only the contents we care about while minimizing the risk of accidental shadowing.
The syntax is the same as destructuring a record:
module MyModule = {
type myType = int
let myValue = 1
let hello = () => Js.log("hello")
}
let {myValue, hello} = module(MyModule)
To avoid shadowing existing bindings, we can also assign aliases to the destructured names:
let myValue = 0
let {myValue : newName} = module(MyModule)
Js.log2(myValue, newName)
Module Examples
Now that we’ve covered the rules of writing and using modules, let’s go over some practical examples by implementing some modules for immutable data structures.
I won’t go over these implementations line-by-line, the purpose of these examples is just to show how concepts from previous chapters can be used together with modules.
Our first example is a module that implements an efficient immutable stack using a List. It defines a set of simple stack operations: creating a new stack, push, pop, peek, and getting the size of the stack. As with other immutable data structures, operations do not mutate the input stack – they simply return a new stack with the operation applied.
Notice that the
size function is actually just an alias for
Belt.List.size, and the
get function provides null safety by returning an option:
module ListStack = {
// initializing a new empty stack
let new = () => list{}
// return new stack with additional element
let push = (stack, element) => {
list{element, ...stack}
}
// return top element of the stack as an option
let peek = stack => {
switch stack {
| list{hd, ..._} => Some(hd)
| _ => None
}
}
// return new stack without top element
let pop = stack => {
switch stack {
| list{_, ...tl} => tl
| _ => stack
}
}
// return number of elements in the stack
let size = Belt.List.size
}
The module can be used as such:
let stack = ListStack.new()
let stack' = stack->ListStack.push(10)
->ListStack.push(100)
->ListStack.push(1000)
->ListStack.pop
stack'->ListStack.size->Js.log
stack'->ListStack.peek->Belt.Option.getExn->Js.log
One important distinction between modules and classes: our module is not a class that contains a list; it is a collection of functions that operates on a list. There is no “instance” of ListStack or anything wrapping the list, the stack is just the list itself, and ListStack is a namespace containing functions that manipulate lists as if they were stacks.
Next, we have an immutable map implemented using a List. In this module, the underlying data type is a list of
key-value pairs. To contrast with the safe
peek function of the stack, here we make the
get function throw if the
mapping is not found:
module ListMap = {
open Belt
// return a new map with mapping for k removed
let remove = (map, k) => {
List.keep(map, ((key, _)) => key !== k)
}
// return a new map with added mapping for k -> v
let set = (map, k, v) => {
list{(k, v), ...remove(map, k)}
}
// return mapped value for k, or throws
let rec get = (map, k) => {
switch map {
| list{} => raise(Not_found)
| list{(key, val), ..._} if key === k => val
| list{_, ...rest} => get(rest, k)
}
}
Here’s how the ListMap
module can be used:
let map = ListMap.new()
let map' = map->ListMap.set("duck", 10)
->ListMap.set("chicken", 25)
let map'' = map'->ListMap.remove("duck")
map->ListMap.size->Js.log
map'->ListMap.size->Js.log
map''->ListMap.size->Js.log
map'->ListMap.get("duck")->Js.log
This is a pretty inefficient implementation of a map because we have to go through the entire list whenever we look up or modify a mapping. Remember that we’re only using this as an example here – for real use cases, Belt.Map is much better.
A final example: an immutable priority queue. This
data structure is implemented using a binary tree, and is also a good demonstration of variants and pattern matching. By taking advantage of recursion to traverse the tree, each function only needs a single switch that handles just the current node (the children are handled by recursive calls):
module PriorityQueue = {
type priority = int
type rec queue<'a> = Empty | Node(priority, 'a, queue<'a>, queue<'a>)
// return new queue w/ added element
let rec insert = (queue, prio, elt) => {
switch queue {
| Empty => Node(prio, elt, Empty, Empty)
| Node(p, e, left, right) =>
if prio <= p {
Node(prio, elt, insert(right, p, e), left)
} else {
Node(p, e, insert(right, prio, elt), left)
}
}
}
// return new queue w/o lowest priority element,
// which is always the topmost node
let rec pop = queue => {
switch queue {
| Empty => queue
| Node(_, _, left, Empty) => left
| Node(_, _, Empty, right) => right
| Node(_, _, Node(lprio, lelt, _, _) as left, Node(rprio, relt, _, _) as right) =>
if lprio <= rprio {
Node(lprio, lelt, pop(left), right)
} else {
Node(rprio, relt, left, pop(right))
}
}
}
// return the lowest-priority element in the queue as an
// optional tuple of (priority, element)
let peek = queue => {
switch queue {
| Empty => None
| Node(prio, elt, _, _) as queue => Some((prio, elt))
}
}
// return size of the queue
let rec size = queue => {
switch queue {
| Empty => 0
| Node(_, _, left, right) => 1 + size(left) + size(right)
}
}
}
Here’s how it would be used:
let pq = PriorityQueue.empty
->PriorityQueue.insert(5, "task A")
->PriorityQueue.insert(10, "task B")
->PriorityQueue.insert(1, "task C")
let first = pq->PriorityQueue.peek
let pq' = pq->PriorityQueue.pop
let second = pq'->PriorityQueue.peek
let pq'' = pq'->PriorityQueue.pop
let third = pq''->PriorityQueue.peek
switch (first, second, third) {
| (Some((p1, j1)), Some((p2, j2)), Some((p3, j3))) => {
Js.log2(p1, j1)
Js.log2(p2, j2)
Js.log2(p3, j3)
}
| _ => Js.log("something went wrong")
}
Console output:
1 task C
5 task A
10 task B
As with other immutable data structures, updating this priority queue doesn’t change any state, and we don’t lose any information about the past. After we’ve popped items off to create pq'', we can access pq and pq' to see the previous states.
Module Signatures
Module signatures are like a type signature for modules. They serve the purpose of separating the interface of a module from its implementation. Multiple modules can implement the same signature, allowing better abstraction and code reuse via functors. Additionally, signatures can be used to restrict which values in a module are visible to its users, which helps programmers avoid leaking implementation details outside the module.
Interface Files
For file-level modules, signatures are defined via an interface file with the same name and a different extension (.resi).
For example, say we have a file named
MyModule.res with the following contents:
let secret = 1
let public = secret * 2
Without an interface,
MyModule.secret and
MyModule.public can be accessed outside of the file. Say we want to hide the existence of
secret outside the module. To do this, we can define an interface for
MyModule inside a
MyModule.resi which only contains the signature for
x:
Now, MyModule.public is still visible outside the module, but MyModule.secret is not.
Defining Module Signatures
Just like modules, module signatures can also be defined inside of files. The definition looks very similar to a module definition, except using module type instead of module. Names of module signatures must be capitalized.
Unlike interface files, inline module signatures aren’t automatically matched up with a module implementation. Instead, each module implementation can choose which signature it implements.
Here’s what the previous example would look like if
MyModule was defined inside a file:
module type MySig = {
let public: int
}
module MyModule : MySig = {
let secret = 1
let public = secret * 2
}
Let’s go over a more complex example to show how multiple modules can implement the same signature.
Here’s a signature that represents an
immutable stack. Since we don’t know the underlying data type representing the stack, we specify that any modules implementing the signature must define a type
t<'a> to represent a stack containing elements of some generic type
'a. This allows us to define the signature of the other functions in the module based on that type:
module type ImmStack = {
type t<'a>
let new: unit => t<'a>
let push: (t<'a>, 'a) => t<'a>
let peek: t<'a> => option<'a>
let pop: t<'a> => t<'a>
let size: t<'a> => int
}
Looking back at our
ListStack implementation from earlier, I mentioned that the module contains a collection of functions that operates on lists. This means that functions from that module can be applied to any list. In other words, the implementation details are leaked. For example, the following snippet would be allowed:
let stack = ListStack.new()
->ListStack.push(1)
->ListStack.push(2)
->Belt.List.reverse
let top = stack->ListStack.peek
Leaking implementation details is undesirable, because it allows users to perform operations that violate the invariants of our module. It doesn’t make sense for functions like Belt.List.reverse to be applied to our stacks, since it breaks the definition of a stack.
In order to prevent illegal operations on our stacks, we can hide the underlying data type for the ListStack module using a signature. If the code outside the module does not know that ListStack is backed by a list, then they can’t use regular list operations to illegally manipulate the stack.
To do this, we just need to add a definition for the underlying data type
t<'a> and make our
ListStack module implement the
ImmStack signature:
module ListStack : ImmStack = {
// Rest of ListStack implementation follows
...
}
Although
ListStack still manipulates lists under the hood, that detail is no longer revealed to users of the module. From the perspective of other modules, all
ListStack functions operate on values of type
ListStack.t, and the only way to create a
ListStack.t is with
ListStack.new. Trying to call standard library list functions on
ListStack.t values outside the module will give a type error, showing that we have successfully hidden its implementation details:
This has type: ListStack.t<'a>
Somewhere wanted: Belt.List.t<'b> (defined as list<'b>)
Now, let’s define another module that can match the ImmStack signature. The implementer of the module can define t<'a> as anything they want, so stacks backed by other data structures are also valid implementations of ImmStack. This time, we’ll define a stack module that stores data using an array.
Since arrays don’t support immutable updates, this is actually a horribly inefficient implementation because it copies the whole array every time we push or pop a value. Luckily for us the user of the module doesn’t have to know that, its signature means that it can be used exactly the same as
ListStack:
module ArrayStack: ImmStack = {
type t<'a> = array<'a>
let push = (stack, element) => {
let copy = stack->Js.Array2.copy
copy->Js.Array2.push(element)->ignore
copy
}
let peek = stack => {
if Js.Array2.length(stack) === 0 {
None
} else {
Some(stack[Js.Array2.length(stack) - 1])
}
}
let pop = stack => {
let copy = stack->Js.Array2.copy
copy->Js.Array2.pop->ignore
copy
}
let size = Js.Array2.length
}
We can also define a third stack implementation using a custom variant type. The discerning reader will notice that this is essentially the same as a list-based stack, with
Empty being analogous to
list{} and
Item(a, b) being analogous to
list{a, ...b}. Therefore, this is significantly more efficient than the array-based
stack:
module MyStack: ImmStack = {
type rec t<'a> = Empty | Item('a, t<'a>)
let push = (stack, element) => {
Item(element, stack)
}
let peek = stack => {
switch stack {
| Item(top, _) => Some(top)
| _ => None
}
}
let pop = stack => {
switch stack {
| Item(_, rest) => rest
| _ => stack
}
}
let rec size = stack => {
switch stack {
| Item(_, rest) => 1 + size(rest)
| _ => 0
}
}
}
Having multiple modules matching the same signature is nice when we need to substitute one module for another, but that’s not all. By combining what modules and signatures with functors, we can finally unlock the true power and reusability of modules.
Functors
Functors are essentially functions for modules: they take in some modules as input, and output a new module. This allows us to create new modules by combining and extending existing modules.
Functors become extremely powerful when combined with the abstraction of module signatures. Just like how functions are defined to accept values that match a particular type, functors are defined so that they accept any module that matches a particular signature. This allows the same functor to be reused on many different input modules, just like how the same function can be called with many different possible arguments.
Defining and Using Functors
To demonstrate how to write and use functors, let’s define a functor that takes in an immutable stack module and returns a mutable stack module.
Functor
definitions look like a combination of module definition and function definitions. Our functor
MakeMutableStack accepts a module that implements
ImmStack and uses its functions in another module definition. The name of the input can be anything, as long as it is capitalized, and it will be referred to by that name inside the module definition:
module MakeMutableStack = (Stack: ImmStack) => {
let new = () => ref(Stack.new())
let push = (stack, element) => {
stack := stack.contents->Stack.push(element)
}
let peek = stack => {
Stack.peek(stack.contents)
}
let pop = stack => {
stack := Stack.pop(stack.contents)
}
let size = stack => Stack.size(stack.contents)
}
This functor is pretty simple – it just stores the immutable stack in a reference and mutates the reference with the updated stack after each update.
Using the functor to define a new module is just like calling a function. We can reuse the same functor to all of our modules that implement
ImmStack to create three new modules for
mutable stacks – neat!
module MyMutableStack = MakeMutableStack(MyStack)
module MutableArrayStack = MakeMutableStack(ArrayStack)
module MutableListStack = MakeMutableStack(ListStack)
Inputs to
functors are required to be annotated with signatures, but we may also optionally provide a signature for the output of a functor. Let’s define a signature for mutable stacks called
MutStack, and use it to describe our functor:
module type MutStack = {
type t<'a>
let new: unit => t<'a>
let push: (t<'a>, 'a) => unit
let peek: t<'a> => option<'a>
let pop: t<'a> => unit
let size: t<'a> => int
}
module MakeMutableStack = (Stack: ImmStack): MutStack => {
type t<'a> = ref<Stack.t<'a>>
// rest of MakeMutableStack follows
}
Now, every module defined using MakeMutableStack will implement MutStack. The type signature of the entire functor is ImmStack => MutStack.
Extending Modules
Functors can be used to extend existing modules with new functionality. For example, let’s say that we want a way to extend any ImmStack module with fromArray and toArray functions. Regardless of the backing data type for the stack, having these functions will help us debug our code and make interoperating with JavaScript easier.
Instead of writing fromArray and toArray separately for each ImmStack implementation, we can just implement them once in a functor and reuse that for all ImmStack modules.
First, let’s extend our signature for
ImmStack with our new operations by defining a new signature. To avoid redeclaring all the
ImmStack functions, we’ll use the
include keyword which does this copy-pasting for us:
module type ExtendedImmStack = {
include ImmStack
let fromArray: array<'a> => t<'a>
let toArray: t<'a> => array<'a>
}
Next, we’ll write a functor that takes an
ImmStack and creates an
ExtendedImmStack. We’ll use
include to copy the implementation for the module we pass in, and define the two new functions. For these two functions, we’ll use the invariant that the top of the stack corresponds to the last element in the array:
module ExtendImmStack : ImmStack => ExtendedImmStack = (Stack: ImmStack) => {
include Stack
let fromArray = (arr: array<'a>) => {
Js.Array2.reduce(arr, (stack, el) => Stack.push(stack, el), Stack.new())
}
let toArray = (stack: Stack.t<'a>) => {
let arr = []
let current = ref(stack)
while Stack.size(current.contents) > 0 {
// we make sure the stack is non-empty in the loop guard, so getExn is safe
let next_val = Stack.peek(current.contents)->Belt.Option.getExn
arr->Js.Array2.push(next_val)->ignore
current := Stack.pop(current.contents)
}
Js.Array2.reverseInPlace(arr)
}
}
We can apply this functor to add these operations to all our
ImmStack implementations:
module MyExtendedStack = ExtendImmStack(MyStack)
module ExtendedArrayStack = ExtendImmStack(ArrayStack)
module ExtendedListStack = ExtendImmStack(ListStack)
With this extension, our stack modules are much easier to print and debug. Without converting to an array, printing the stack would have looked something like this:
let s = MyExtendedStack.fromArray([1, 2, 3])
let s' = s->MyExtendedStack.push(4)
Console output (terrible):
{ _0: 4, _1: { _0: 3, _1: { _0: 2, _1: [Object] } } }
By using our new function to convert our stack to an array before printing, it’s much more readable:
let s = MyExtendedStack.fromArray([1, 2, 3])
let s' = s->MyExtendedStack.push(4)
s'->MyExtendedStack.toArray->Js.log
Although our functor’s implementations of fromArray and toArray aren’t the most efficient possible implementations for ArrayStack (which is already an array under the hood), there are benefits to having a single implementation. There is less code to maintain, and we don’t have to make sure that each separate implementation respects the same invariants.
Functors in the Standard Library
Functors are an important part of ReScript’s standard library, because standard library functions and modules need to be able to handle all kinds of data types.
We’ve already used functors in an earlier chapter – both Belt.Id.MakeComparable and Belt.Id.MakeHashable are examples of functors. They allow the sets and maps provided in the standard library to be used with any data type we want, as long as we provide a module that defines comparison or hashing operations for that data type.
For example, the signature of
Belt.Id.MakeComparable looks like this:
module MakeComparable: (M: {
type t
let cmp: (t, t) => int
}) => Comparable with type t = M.t
It’s a functor that takes in any module that defines a type t and a cmp function for comparing two values of that type, and outputs a module that matches the Comparable signature.
Recall how we used this functor to create a new module to compare pairs of integers in our battleship example:
module CoordCmp = Belt.Id.MakeComparable({
type t = (int, int)
let cmp = (a, b) => Pervasives.compare(a, b)
})
One interesting thing to note in the previous snippets is the use of anonymous modules and module signatures. We do not have to declare a module or signature beforehand to use it in a functor; we can provide the definition or signature in-line.
If we were to modify the previous snippet to use a named module, it would look something like this:
module Coord = {
type t = (int, int)
let cmp = (a, b) => Pervasives.compare(a, b)
}
module CoordCmp = Belt.Id.MakeComparable(Coord)
Final Thoughts
In this chapter, we discussed how to define and use modules in ReScript, and how to transform modules using functors. Modules are especially vital to writing large software applications in ReScript, because they enable namespacing, abstraction, and code reuse.
Organizing code into modules allows us to group different parts of the application logically, so that it’s not just a collection of functions and types at the top level. Modules and signatures also allow us to define interfaces between different parts of our application and hide unnecessary implementation details. Finally, functors allow us to operate on a higher level of abstraction, allowing us to transform modules and reuse or extend their functionality.
With this chapter, we’ve covered all of the main language features in ReScript as well as many fundamental concepts in functional programming. You should be well-equipped to write clean, organized, and efficient programs in ReScript. In the final chapter, we’ll apply these concepts to the real world: we’ll discuss how to write ReScript programs that interoperate with JavaScript, and integrate JavaScript libraries into a simple ReScript web application.