© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2023
D. YangIntroducing ReScripthttps://doi.org/10.1007/978-1-4842-8888-7_7

7. Modules

Danny Yang1  
(1)
Mountain View, CA, USA
 

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:
module MyModule = {}
module MyModule = {}
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()
Console output:
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:
Hello.helloWorld()
Compiling the project and running Myfile.bs.js prints the following in console:
hello, world
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
Console output:
blah

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
Console output:
blah
blah
blah

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:
let x = 1
open MyModule.MyNestedModule
x->Js.log
Console output:
blah
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)
Console output:
true
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:
open Belt
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)
Console output:
true

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)
Js.log(myValue)
hello()
Console output:
1
hello
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)
Console output:
0 1

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
Console output:
2
100

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
 let new = () => list{}
 // 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)
   }
 }
 let size = List.size
}
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
Console output:
0
2
1
10

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>)
 let empty = Empty
 // 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:
let public: int

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 = {
 type t<'a> = list<'a>
 // 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 new = () => []
 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 new = () => Empty
 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)
s'->Js.log
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
Console output (nice!):
[1, 2, 3, 4]

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset