© 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_8

8. JavaScript Interoperability

Danny Yang1  
(1)
Mountain View, CA, USA
 

Interoperability with JavaScript is one of the highlights of ReScript that makes it well-suited for web development. It allows ReScript programs to use the huge ecosystem of JavaScript libraries that many web developers are already familiar with. Interoperability also makes it easy to convert parts of an existing JavaScript code base into ReScript while maintaining compatibility with existing code, whether for experimentation purposes or as part of a wider migration effort.

In this chapter, we’ll discuss the runtime representations of various ReScript data types, how to import and use JavaScript values or functions from ReScript and vice versa, and strategies for parsing and validating JSON. Finally, we’ll see those techniques in action by writing a simple web application entirely using ReScript.

Calling JavaScript from ReScript

Since JavaScript functions don’t come with types, it’s up to the programmer to define their signatures when they are called from ReScript. Signatures for external functions are called “bindings,” and they basically tell the ReScript compiler, “there’s no definition in ReScript for it, but a function exists somewhere with this signature.”

Let’s go over a simple example of importing a JavaScript module and writing ReScript bindings.

First, we’ll create a simple JavaScript file called jsmodule.js that contains a single function that we’ll be calling from ReScript:
let getCurrentYear = () => {
   return new Date().getFullYear();
}
exports.getCurrentYear = getCurrentYear
Next, we’ll create a Rescript file called CallJsDemo.res. Inside, we’ll write the binding for the function. Bindings for both values and functions are structured according to the following format:
<annotation>
external <ReScript binding name>: <type signature> = "<JavaScript name>"
In our case, the binding will look like this:
@module("./jsmodule")
external getCurrentYear: () => int = "getCurrentYear"
Let’s break down the components of the binding:
  • @module("./jsmodule") – This tells the compiler which module we are importing the function from.

  • getCurrentYear – This is the name that we’ll use to call the function from ReScript. It doesn’t have to match the name of the function in JavaScript!

  • () => int – This is the type signature of the function, which takes in no arguments and returns an integer.

  • "getCurrentYear" – This is the real name of the JavaScript function we’re binding to.

Now that we have a binding, we can call the function just like we would call any other ReScript function:
@module("./jsmodule")
external getCurrentYear: () => int = "getCurrentYear"
getCurrentYear()->Js.log
When the @module annotation is used, ReScript knows to automatically import the module at the top of the compiled output, which looks like this:
'use strict';
var JsModule = require("./jsmodule");
function getCurrentYear(prim) {
 return JsModule.getCurrentYear();
}
console.log(JsModule.getCurrentYear());
exports.getCurrentYear = getCurrentYear;
When we run the compiled code, we can see that it successfully calls the JavaScript function and prints the output. Note that the output may be different depending on what year you are reading this!
> node src/CallJsDemo.bs.js
2022
Sometimes, automatically importing a module is not desirable, particularly when binding to the standard library. For example, let’s say we want to call JSON.parse. If we use @module("JSON"), the compiler will try to import a module called JSON at the beginning of the file. To avoid this, we can use @scoped instead:
@scope("JSON") @val
external parse: string => 'a = "parse"
let obj = parse("{}")
The compiled output contains no imports:
var obj = JSON.parse("{}");
There are other annotations that are useful for binding to JavaScript, which we’ll go over in later examples:
  • @val for global values not in any scope

  • @variadic for variadic functions (functions that take a variable number of arguments)

  • @get and @set for object properties

  • @send for methods

It’s also worth mentioning that functions are not the only thing that we can write bindings for. For example, if we want to share constants between JavaScript and ReScript, we can. As long as there’s a way to express the value’s type in ReScript, we can write a binding for it.

We’ll go over examples of different types of annotations and bindings later in the chapter.

Embedding Raw JavaScript in ReScript Files

In addition to writing bindings to call external JavaScript functions, we can also embed raw JavaScript directly inside ReScript files. This is not recommended for production code because it’s not type-safe, but it can be useful for practicing or if we want to quickly prototype something.

To embed top-level JavaScript definitions and code, use %%raw() with the JavaScript code snippet surrounded by backticks:
%%raw(`
 // put your Javascript code here!
`)
To actually call functions defined in those blocks, we need to bind them just like we would any other external JavaScript code:
%%raw(`
function hello() {
   console.log("hello!");
}
`)
@val external hello: () => unit = "hello"
hello()
Console output:
hello!
If we want to embed an expression instead of an entire code block, we can use %raw() (note the single %):
let hello = %raw(`
 function() {
   console.log("hello!");
 }
`) hello()
Console output:
hello!

Calling ReScript from JavaScript

Now, let’s show how ReScript can be called from JavaScript. Since ReScript files are compiled into JavaScript files, calling ReScript from JavaScript is more or less the same as calling any other JavaScript file.

Exporting Values from ReScript

By default, all top-level values declared in a ReScript file are exported when they’re compiled into a JavaScript module.

The ReScript file here:
let a = [1, 2, 3]
let b = a[0]
Compiles to this JavaScript:
'use strict';
var Caml_array = require("rescript/lib/js/caml_array.js");
var a = [1, 2, 3];
var b = Caml_array.get(a, 0);
exports.a = a;
exports.b = b;

To limit which values are exported, we can create an interface file (.resi). For files that have an associated interface, only the values declared in the interface are exported from the module.

We can add a .resi file with the following contents to avoid exporting a from the previous example:
let b : int
The output JavaScript code does not export that value anymore:
'use strict';
var Caml_array = require("rescript/lib/js/caml_array.js");
var a = [1, 2, 3];
var b = Caml_array.get(a, 0);
exports.b = b;

Using ReScript Modules from JavaScript

Let’s go over an example of how we would actually use a ReScript module from JavaScript.

The ReScript module we’re using will look familiar: it is the ListStack module from the previous chapter. Make a file called ListStack.res with the following contents:
type t<'a> = list<'a>
let new = () => list{}
let push = (stack, element) => {
 list{element, ...stack}
}
let peek = stack => {
 switch stack {
 | list{hd, ..._} => Some(hd)
 | _ => None
 }
}
let pop = stack => {
 switch stack {
 | list{_, ...tl} => tl
 | _ => stack
 }
}
let size = Belt.List.size
Then, in the same directory, make a JavaScript file called usestack.js with the following contents:
var ListStack = require("./ListStack.bs");
var stack = ListStack.$$new();
var values = [9, 3, 5]
values.forEach(x => {
   stack = ListStack.push(stack, x);
});
while (ListStack.size(stack) !== 0) {
   console.log(ListStack.peek(stack))
   stack = ListStack.pop(stack)
}

Notice that the ReScript module is imported and used just like any other JS module. The only difference is that the new function had to be escaped since “new” is a keyword in JavaScript.

To run the example, first compile the ReScript file to JS and then use node to run usestack.js. The terminal will show the following:
5
3
9

Shared Data Types

Many data types in ReScript compile directly to logical JavaScript equivalents, so they can be passed back and forth between ReScript and JavaScript without issues. The following is a chart mapping ReScript data types to their JavaScript runtime representations:
  • string -> string

  • bool -> boolean

  • float -> number

  • object/record/Dict -> object

  • array/tuple -> array

  • unit -> undefined

  • module -> module

In some cases, multiple ReScript data types can map to the same JavaScript data type; for example, both arrays and tuples in ReScript compile to JavaScript arrays.

To demonstrate why this matters, we’ll use some JavaScript functions that operate on arrays with two elements:
let makePair = (x, y) => [x, y];
let getX = pair => pair[0];
let getY = pair => pair[1];

In ReScript, since both tuples and arrays compile to JavaScript arrays, either type can be used for these functions. This gives us the ability to choose the representation that is most compatible with our use case.

This is what the bindings for these functions would look like if we chose to use arrays:
%%raw(`
let makePair = (x, y) => [x, y];
let getX = pair => pair[0];
let getY = pair => pair[1];
`)
@val external makePairArray: (int, int) => array<int> = "makePair"
@val external getXArray: array<int> => int = "getX"
@val external getYArray: array<int> => int = "getY"
let pair = makePairArray(3, 5)
getXArray(pair)->Js.log
getYArray(pair)->Js.log
And here’s how we would write bindings for these functions using tuples:
@val external makePairTuple: (int, int) => (int, int) = "makePair"
@val external getXTuple: ((int, int)) => int = "getX"
@val external getYTuple: ((int, int)) => int = "getY"
let pair = makePairTuple(3, 5)
getXTuple(pair)->Js.log
getYTuple(pair)->Js.log

By using different names, we can create separate bindings with different types which bind to the same function. Both sets of bindings can exist at the same time in our ReScript program, and ReScript treats them as different functions even though they are the same JavaScript function under the hood.

Declaring multiple bindings with specialized types for the same JavaScript function is a common pattern – JavaScript libraries are generally less strict about what types are allowed, and having more specialized type signatures allows ReScript’s typechecker to provide more guarantees that we’re using these functions correctly.

Other data types can also be shared between ReScript and JavaScript, but require special considerations.

Integers

Like floats, integers also compile to JavaScript numbers, but larger values will be truncated to 32 bits.

For example, the following code to output the truncated value 1445688163, not 1659303064419 as we would expect:
let add1 = (x: int) => x + 1
%%raw(`
// some large 64 bit number
let x = 1659303064418;
console.log(add1(x));
`)
On the other hand, floats will not be truncated, giving us the desired output:
let add1 = (x: float) => x +. 1.
%%raw(`
// some large 64 bit number
let x = 1659303064418;
console.log(add1(x));
`)

This means that large numbers like timestamps should generally be handled as floats to avoid any unexpected truncation.

Functions

Trying to partially apply an imported JavaScript function in ReScript may cause a runtime failure. This usually only happens in situations when we call functions without any defined bindings, but it is good to be aware of.

For example, the following code is an unsafe call to NodeJS’s path.join function:
%%raw(`
var path = require('path');
`)
@val external path: 'a = "path"
let joined = path["join"]("a", "b")
Js.log(joined)
It compiles to the following JavaScript:
var Curry = require("rescript/lib/js/curry.js");
var joined = Curry._2(path.join, "a", "b");
console.log(joined);
Running the code will throw an exception at runtime:
.../node_modules/rescript/lib/js/curry.js:14
     return f.apply(null, args);
              ^
TypeError: f.apply is not a function
The solution to this is to force the call to be uncurried by putting a . in front of the arguments:
%%raw(`
var path = require('path');
`)
@val external path: 'a = "path"
let joined = path["join"](. "a", "b")
Js.log(joined)
This makes the generated code apply the arguments normally:
var joined = path.join("a", "b");
console.log(joined);
The code now successfully runs:
a/b

Problems with currying should be rare, but they are easy to spot and fix. When debugging a potential currying problem, search the generated JavaScript code for Curry, and fix the problem by making the call site or function definition uncurried.

Options

Options have no runtime wrapper, and compile to either the value (if present) or undefined if not. The lack of a runtime wrapper makes options easy to work with – if we want to use an optional value passed from ReScript, just check to see if it’s undefined first and then we can use it directly:
let x = Some(1)
let y = None
Js.log(x)
Js.log(y)
Console output:
1
undefined

Other Data Types

Some of the data types that we discussed in the earlier chapters do not have a 1:1 equivalent in JavaScript, such as
  • Most collections (maps, sets, lists, etc.)

  • Variants

Immutable Data Structures

Maps, sets, and lists are JavaScript objects at runtime, but they may be deeply nested and difficult to read if we try to print them for debugging. They also should not be directly serialized into JSON. The best approach is to convert them to arrays using toArray before printing or serializing them, although be aware that frequent conversions of large collections can be expensive.

Variants

Variants which do not have data associated with them will compile to a number corresponding to the tag. Which number is associated with which tag depends on the order the tags are declared in. For example, in the variant type type color = White | Black, White compiles to 0 and Black compiles to 1.

The runtime representation of a variant with associated data is a JavaScript object, with the special TAG property containing the tag number and the associated data is stored in other special properties. Those properties _0, _1, _2, etc. may be present depending on how many pieces of data are associated with the tag. If the data associated with a tag is a single value or some other data type like an array or record, that value is stored in _0.

Let’s demonstrate this using a simple variant type:
type entity = Player(string, int) | Enemy(int)
The expression Player("Danny", 10) compiles to:
{
 TAG: 0,
 _0: "Danny",
 _1: 10
}
The expression Enemy(10) compiles to:
{
 TAG: 1,
 _0: 10
}
Let’s see how we would manipulate these values from JavaScript by writing a JavaScript function that takes an entity and returns a new entity with increased health:
%%raw(`
function addHealth(e, n) {
   if (e.TAG === 0) {
       return {TAG: 0, _0: e._0, _1: e._1 + n};
   } else if (e.TAG === 1) {
       return {TAG: 1, _0: e._0 + n};
   }
}
`)
@val
external addHealth: (entity, int) => entity = "addHealth"
Calling this function and inspecting the console reveals the structure of the variant data type:
let entity1 = Player("Danny", 10)
entity1->Js.log
entity1->addHealth(5)->Js.log
let entity2 = Enemy(5)
entity2->Js.log
entity2->addHealth(5)->Js.log
Console output:
{ TAG: 0, _0: 'Danny', _1: 10 }
{ TAG: 0, _0: 'Danny', _1: 15 }
{ TAG: 1, _0: 5 }
{ TAG: 1, _0: 10 }

As you can see, it is pretty difficult to work with variants directly in JavaScript, because we have to be aware of a lot of low-level details about how variants are represented.

In general, most logic dealing with variants should be kept within ReScript code, and variants that need to be passed to JavaScript should represent their data using more convenient data types such as records or arrays, which can be unwrapped before passing.

Polymorphic Variants

Polymorphic variants are an advanced feature of ReScript. Unlike regular variants which compile to numbers, polymorphic variants compile to the name of the tag. This makes them useful for representing static constants such as enums.

Polymorphic variant declarations look a bit different from regular variants – the tags are surrounded by square brackets and tag names are prefixed with #:
type color = [ #Black | #White ]

With the preceding polymorphic variant, the value #White compiles to the string literal "White" and #Black compiles to "Black.” If the tag name is a number (e.g., #100), then the variant value will compile to a number literal as well.

Polymorphic variants with associated data still compile to objects like regular variants, but structure of the object is from regular variants. The tag name is stored as a string in the NAME property, while all the data associated with the tag is stored in the VAL property.

Here’s the player/enemy example from before, rewritten using polymorphic variants:
type entity = [ #Player(string, int) | #Enemy(int) ]
%%raw(`
function addHealth(entity, n) {
   if (entity.NAME === "Player") {
       return {NAME: "Player", VAL: [entity.VAL[0], entity.VAL[1] + n]};
   } else if (entity.NAME === "Enemy") {
       return {NAME: "Enemy", VAL: entity.VAL + n};
   }
}
`)
@val
external addHealth: (entity, int) => entity = "addHealth"
let entity1 = #Player("Danny", 10)
entity1->Js.log
entity1->addHealth(5)->Js.log
let entity2 = #Enemy(5)
entity2->Js.log
entity2->addHealth(5)->Js.log
Running the code and inspecting the output reveals the underlying structure of our polymorphic variants:
{ NAME: 'Player', VAL: ['Danny', 10] }
{ NAME: 'Player', VAL: ['Danny', 15] }
{ NAME: 'Enemy', VAL: 5 }
{ NAME: 'Enemy', VAL: 10 }

Thanks to their superior readability when compiled, polymorphic variants are better than regular variants for writing interoperable code. The data is easier to unwrap when passing from ReScript to JavaScript, and the tag names are more readable when we need to write JavaScript code that works with variants.

Working with Null

One of the big advantages of ReScript over JavaScript is null safety via options. However, recall that the optional value None is actually undefined at runtime, not null.

Normally we don’t need to deal with null values directly in ReScript, but the distinction between null and undefined matters when we pass possibly null or possibly undefined values from JavaScript to ReScript, or if we need to return a nullable value to JavaScript.

The standard library module Js.Nullable allows us to handle nulls in ReScript. Using this module, we can represent null in ReScript as Js.Nullable.null. Similarly, undefined and non-null values can be represented using this module:
let _ = Js.Nullable.null
let _ = Js.Nullable.undefined
let _ = Js.Nullable.return(1)

Note that the Js.Nullable type is a different type than the option type – the former represents a value that could be undefined or null, whereas the latter represents values that could be undefined but can never be null.

We can convert between nullable and option by using Js.Nullable.fromOption and Js.Nullable.toOption – when converting from nullable to option, null gets converted into None/undefined:
let null: Js.Nullable.t<int> = Js.Nullable.null
let option: option<int> = Js.Nullable.toOption(null)
Js.log(option)
Js.log(option === None)
Console output:
undefined
true
Use Js.Nullable.t as the parameter or return type where appropriate when binding to JavaScript functions that accept nullable inputs or return nullable values:
%%raw(`
function testNullable(input) {
   if (input === null) {
       console.log("the value is null");
   } else if (input == undefined) {
       console.log("the value is undefined");
   } else {
       console.log("the value is " + input);
   }
}
`)
@val
external testNullable: Js.Nullable.t<int> => unit = "testNullable"
testNullable(Js.Nullable.null)
testNullable(Js.Nullable.undefined)
testNullable(Js.Nullable.return(1))
testNullable(Js.Nullable.fromOption(Some(1)))
testNullable(Js.Nullable.fromOption(None))
Console output:
the value is null
the value is undefined
the value is 1
the value is 1
the value is undefined

Working with Exceptions

ReScript’s exceptions are a little different from exceptions thrown and caught in regular JavaScript code. Although overusing exceptions is bad, sometimes it is necessary for Rescript to interoperate with JavaScript exceptions. Utilities for working with JavaScript exceptions are found in the Js.Exn module.

Catching ReScript Exceptions in JavaScript

ReScript exceptions can be caught by JavaScript. For a particular caught exception e, the name of the exception can be found at e.RE_EXN_ID, and the stack trace can be found in e.Error.stack:
let foo = () => {
 raise(Not_found)
}
%%raw(`
try {
 foo();
} catch (e) {
 console.log(e.RE_EXN_ID);
}
`)
Console output:
Not_found

Extra information associated with a ReScript exception can be accessed in the exception object in a similar fashion as with variants.

For example, given the definition exception MyException(string), catching MyException("foo") in JavaScript yields the following object (the first positional argument is in _1, the second is in _2, etc.):
{
  RE_EXN_ID: "MyException",
  _1: "Foo",
  Error: ... // error object containing trace information
}
Given the definition exception CustomException({customMessage: string}), catching CustomException({customMessage: "foo"}) in JavaScript yields the following object, allowing us to easily access named fields in custom exceptions:
{
  RE_EXN_ID: "CustomException",
  customMessage: "Foo",
  Error: ... // error object containing trace information
}
We can also throw a generic JavaScript exception from Rescript using Js.Exn.raiseError:
let foo = () => {
 Js.Exn.raiseError("oh no")
}
%%raw(`
try {
 foo();
} catch (e) {
 console.log(e.message);
}
`)
Console output:
oh no

Catching JavaScript Exceptions in ReScript

When we want to use ReScript to call certain JavaScript APIs that may throw, we can handle possible exceptions using try/catch or pattern matching. JavaScript exceptions can be matched with Js.Exn.Error, and handling for JavaScript exceptions can be mixed with handling for regular ReScript exceptions.

Here’s how we would catch a JavaScript exception from ReScript:
try {
 Js.Exn.raiseError("oh no")
} catch {
| Js.Exn.Error(e) => e->Js.Exn.message->Js.log
}

Working with JSON

There are several ways of serializing and deserializing JSON in ReScript, with different levels of convenience and type safety.

Option 1: Binding Without Types

The quick and dirty way to parse JSON is by writing a binding for JSON.parse with the 'a return type. With that binding, we can parse arbitrary JSON strings and access the parsed object however we want. This is the least-safe option for JSON parsing in ReScript:
@scope("JSON") @val
external parse: string => 'a = "parse"
The inferred types for the parsed object are entirely based on what fields we access and how we use them, which grants us the flexibility to parse anything we want and access whatever values we want without worrying about writing types:
let parsed = parse(`{ "name": "Creamsicle", "age": 13, "owner": "Danny" }`)
Js.log(parsed["name"])
let parsed = parse(`{ "player": 1, "score": 200}`)
Js.log(parsed["score"])

This is very nice for prototyping, but comes at the cost of type safety: the type system does not know what type the parsed object is, and does not do any validation to see if the JSON object is actually what we expect.

The compiler will not stop us from accessing a field that does not exist (whose value is undefined at runtime):
let parsed = parse(`{ "player": 1, "score": 200}`)
Js.log(parsed["hello"])
Console output:
undefined
Nor will it stop us from lying to the compiler about what type it is:
let parsed = parse(`{ "player": 1, "score": 200}`)
let x: string = parsed["hello"]

This can result in incorrect values polluting our otherwise safe code, causing errors to be thrown in unexpected places in our program. While this approach is very flexible, it’s also quite unsafe – about as unsafe as parsing JSON in regular JavaScript.

Option 2: Binding With Types

One way to make JSON parsing safer is to write a type for the parsed object, and write separate bindings for each type of object that needs to be parsed. The type signature can be anything, but commonly it will be an object, record, or Dict:
type player = {
   "player": int,
   "score": int,
}
@scope("JSON") @val
external parsePlayer: string => player = "parse"
let parsed = parsePlayer(`{ "player": 1, "score": 200}`)
Js.log(parsed["score"])
Console output:
200
Now, if we use our custom parsePlayer binding to parse the JSON, the compiler will prevent us from accessing undefined properties from the parsed object:
Js.log(parsed["hello"])
Compiler output:
 This expression has type player
It has no method hello

While this is a nice upgrade, it only solves part of the problem. Since parsePlayer takes in arbitrary strings, there is nothing to stop us from passing in strings that do not correspond to the specified type.

The following example shows that this is still unsafe, because we can to trick the compiler into thinking that score is an int when in fact it is undefined:
type player = {
   "player": int,
   "score": int,
}
@scope("JSON") @val
external parsePlayer: string => player = "parse"
let parsed = parsePlayer(`{ "player": 1}`)
let score: int = parsed["score"]

While unexpected values at runtime might feel normal to someone coming from JavaScript, parsing JSON does not have to be this unsafe in ReScript. Let’s see look at a safer way to parse JSON using ReScript’s standard library.

Option 3: With Validation

Safe JSON parsing in ReScript is supported by the Js.Json standard library. Using it, we can write functions to parse JSON values and validate if the input string actually corresponds to the object that we expect. Errors can be caught and handled when the JSON string is parsed, instead of when we try to use the parsed value.

Although this requires writing a custom parsing function for each object type we want to parse, this approach gives us the highest level of control and safety.
let parsePlayer = (s: string) => {
 open Belt.Option
 let parsed = Js.Json.parseExn(s)
 let obj = Js.Json.decodeObject(parsed)->getExn
 let player = obj->Js.Dict.get("player")->flatMap(Js.Json.decodeNumber)->getExn->Belt.Float.toInt
 let score = obj->Js.Dict.get("score")->flatMap(Js.Json.decodeNumber)->getExn->Belt.Float.toInt
 {
   "player": player,
   "score": score,
 }
}
let parsed = parsePlayer(`{ "player": 1}`)
Running the preceding program at runtime throws a Not_Found exception, because the “score” field does not exist in the JSON we are parsing:
 RE_EXN_ID: 'Not_found',
Error: Error
To prevent runtime exceptions, we can just catch any exceptions inside the function and make it return an optional value, returning None if we fail to parse instead of crashing the program:
let parsePlayer = (s: string) => {
 open Belt.Option
 try {
   let parsed = Js.Json.parseExn(s)
   let obj = Js.Json.decodeObject(parsed)->getExn
   let player = obj->Js.Dict.get("player")->flatMap(Js.Json.decodeNumber)->map(Belt.Float.toInt)
   let score = obj->Js.Dict.get("score")->flatMap(Js.Json.decodeNumber)->map(Belt.Float.toInt)
   Some({
     "player": player->getExn,
     "score": score->getExn,
   })
 } catch {
 | _ => None
 }
}
let parsed = parsePlayer(`{ "player": 1}`)
switch parsed {
| Some(_) => Js.log("we parsed it :)")
| _ => Js.log("failed to parse :(")
}
Console output:
failed to parse :(
We can further customize our parsing behavior by returning a result with a custom error message to aid debugging:
let parsePlayer = (s: string): result<player, string> => {
 open Belt
 let parsed = try {
   Ok(Js.Json.parseExn(s))
 } catch {
 | _ => Error("could not parse")
 }
 parsed
 ->Result.flatMap(p =>
   switch Js.Json.decodeObject(p) {
   | Some(o) => Ok(o)
   | None => Error("expected object")
   }
 )
 ->Result.flatMap(obj => {
   let player =
     obj->Js.Dict.get("player")->Option.flatMap(Js.Json.decodeNumber)->Option.map(Float.toInt)
   let score =
     obj->Js.Dict.get("player")->Option.flatMap(Js.Json.decodeNumber)->Option.map(Float.toInt)
   switch (player, score) {
   | (None, _) => Error("expected player:int")
   | (_, None) => Error("expected score:int")
   | (p, s) =>
     Ok({
       "player": p,
       "score": s,
     })
   }
 })
}
let parsed = parsePlayer(`{ "player": 1}`)
switch parsed {
| Ok(result) => Js.log(result)
| Error(message) => Js.log(message)
}
Console output:
expected score:int

Although this manual parsing code is quite cumbersome to write, it provides a lot of safety benefits by validating our payload before we try to use it. Luckily, when writing large-scale applications, there are various third-party libraries we can use to generate this code for us.

Putting It All Together: Simple ReScript Web App

To put together everything we’ve discussed in this chapter, let’s create a web app from scratch. The app is very simple and consists of a client and server component, both written entirely in ReScript:
  • The server will be a simple Express server providing an endpoint that takes in a name and returns a greeting for that name.

  • The client will have an input to enter the name, a button to make a request to the server with the name, and some code to display the server’s response on the web page.

We’ll build two versions of the app, to show different ways to develop with ReScript:
  1. 1.

    First, we’ll write handwritten bindings for the external functions that we use, to demonstrate how to write bindings to a variety of external APIs.

     
  2. 2.

    Next, we’ll implement the same thing using prewritten bindings from a package, to demonstrate how we can use other people’s bindings in our own code.

     

The second example is ideally how someone would write a web application in ReScript, but since not every JavaScript library has ReScript bindings, we sometimes have to fall back to the techniques in the first example to write our own bindings.

Version 1: Handwritten Bindings

Although ReScript bindings exist for Express, Node, and the DOM, we’ll be writing everything from scratch in this example to demonstrate how to write bindings. Once we finish with both examples, compare the bindings we wrote to the ones we imported. Also keep in mind that there isn’t a single correct way to write bindings – a particular function or value might be bound to different types in different use cases, depending on what is safest and most ergonomic.

Before we begin coding, we need to set up a new ReScript project. Refer to the first chapter for detailed instructions if necessary. We will also need to install the express package for our server:
npm install express
In our project directory, we will create four files:
  • index.html

  • Client.res

  • Client.resi

  • Server.res

Client

First, we’ll make the HTML page that our application will display. Create an index.html file with the following contents:
<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
   <h1>ReScript web app Demo</h1>
   <p>Enter Your Name:</p>
   <input id="name" type="text"/>
   <button id="submit">Submit</button>
   <p id="result"></p>
   <script type="text/javascript" src="./script"></script>
</body>
</html>

Next, we’ll write the client-side code that will be loaded with the web page. This code will go inside Client.res. The corresponding interface Client.resi will be completely empty, which means the compiled JavaScript won’t export any values.

The client code adds a listener to the submit button; when the button is clicked, it will read the contents of the text input and send a request to the server. Once the server responds, it will take the response and display it on the web page by modifying the innerHTML of our result element.

If we were to implement this using JavaScript, it would look something like this:
var submit = document.getElementById("submit");
var result = document.getElementById("result");
var input = document.getElementById("name");
submit.addEventListener("click", param => {
   var payload = {
       method: "POST",
       headers: {
           Accept: "application/json",
           "Content-Type": "application/json"
       },
       body: JSON.stringify({
           name: input.value
       })
   };
   fetch("/hello", payload)
   .then(val => Promise.resolve(val.json()))
   .then(val => {
       result.innerHTML = val.message;
   });
});

To implement this from scratch in ReScript, we need to start by writing bindings for the client code.

For the DOM, there are several different types of bindings we’ll make:
  • Globals: document

  • Functions/methods: getElementById, addEventListener, fetch

  • Object properties: value, innerHTML

The binding for document uses the standard library type Dom.document. Although ReScript’s standard library provides type names for DOM elements, it doesn’t provide any definitions – they’re purely there for us to use when we write our own bindings:
@val external document: Dom.document = "document"
To access elements on the page, we use getElementById:
@send external getElementById: (Dom.document, string) => Dom.element = "getElementById"
In JavaScript, this is a method that takes in a string and returns an element. Here, our binding takes in a Dom.document in addition to the string. This allows us to use the pipe operator to emulate JavaScript’s method call syntax:
let element = document->getElementById("blah")

Notice the @send annotation on the binding. This lets the ReScript compiler know that we are binding to a method. With this annotation, function calls like x->f(y) or f(x, y) get compiled to x.f(y). Without the annotation, it would get compiled like a normal function, to f(x, y).

We’ll also provide bindings for the element’s addEventListener method the same way:
@send external addEventListener: (Dom.element, string, unit => unit) => unit = "addEventListener"
In our code, we also need to access the value property of the input element, and set the innerHTML property of another element. One way to do this is to model these properties as getter and setter functions, using the @get and @set annotations:
@get external getValue: Dom.element => string = "value"
@set external setInnerHTML: (Dom.element, string) => unit = "innerHTML"
The @get annotation causes x->f and f(x) to be compiled to x.f, while the @set annotation causes x->f(y) and f(x, y) to be compiled to x.f = y. The two external functions can be called as such:
let value = element->getValue
element->setInnerHTML("blah")
Next, we’ll write bindings for the fetch API. This is a global function that takes in a URL and a payload, and returns a promise for the response:
type response
@val external fetch: (string, 'a) => Js.Promise.t<response> = "fetch"

The response object has a json method which allows us to extract the JSON payload. This method should return a JavaScript object, which we may not know the exact shape of.

In this situation, we can represent the object as a Dict whose values are Js.Json.t. When we access the dict and use its values, those values will need to be coerced into the expected types using functions such as Js.Json.decodeString.
@send external json: response => Js.Dict.t<Js.Json.t> = "json"
All together, the fetch API call and response handling looks like this:
 open Js.Promise
open Belt.Option
open Js.Json
fetch("/hello", payload)
->then_(response => response->json->resolve, _)
->then_(json => {
  let message = Js.Dict.get(json, "message")->flatMap(decodeString)->getExn
  result->setInnerHTML(message)
  resolve()
}, _)
->ignore
With all our bindings, the client-side code in ReScript looks like this:
@val external document: Dom.document = "document"
@send external getElementById: (Dom.document, string) => Dom.element = "getElementById"
@send external addEventListener: (Dom.element, string, unit => unit) => unit = "addEventListener"
@get external getValue: Dom.element => string = "value"
@set external setInnerHTML: (Dom.element, string) => unit = "innerHTML"
type response
@val external fetch: (string, 'a) => Js.Promise.t<response> = "fetch"
@send external json: response => Js.Json.t = "json"
let submit = document->getElementById("submit")
let result = document->getElementById("result")
let input = document->getElementById("name")
submit->addEventListener("click", _ => {
 let payload = {
   "method": "POST",
   "headers": {
     "Accept": "application/json",
     "Content-Type": "application/json",
   },
   "body": Js.Json.stringifyAny({
     "name": input->getValue,
   }),
 }
 open Js.Promise
 open Belt.Option
 open Js.Json
 fetch("/hello", payload)
 ->then_(response => response->json->resolve, _)
 ->then_(json => json->decodeObject->getExn->resolve, _)
 ->then_(obj => {
   let message = Js.Dict.get(obj, "message")->flatMap(decodeString)->getExn
   result->setInnerHTML(message)
   resolve()
 }, _)
 ->ignore
})

Aside from the bindings, the ReScript implementation should look quite similar to the JavaScript implementation shown earlier.

Client Bundling

Since the client code has dependencies on ReScript’s standard libraries, we need to use a bundler to include those dependencies in the code we send to the browser.

We’ll use esbuild in this example since it is lightweight and fast, but in other projects feel free to use any bundler you want. To install esbuild, simply run the following:
npm install esbuild
We can add a command to run esbuild to our package.json; that way we don’t have to remember any flags:
...
 "scripts": {
   "build": "rescript",
   "clean": "rescript clean -with-deps",
   "start": "rescript build -w",
   "esbuild": "esbuild src/Client.bs.js --outfile=src/Client.js --bundle"
 },
...

Finally, we can compile and bundle the client code by running npm run build and npm run esbuild. The latter should take Client.bs.js and give us a bundled Client.js, which is what we’ll load in the browser.

Server

For the server, we’ll implement a simple Express web server in Server.res. It has three endpoints:
  • / – Serves our web page

  • /script – Serves the JavaScript file that is used by the web page

  • /hello – Takes a JSON payload containing some text, prepends “Hello,” to the text, and returns a JSON payload with the result

The server would look something like this if we implemented it in JavaScript:
var app = express();
app.use(express.json());
app.get("/", (param, response) =>
   response.sendFile(path.join(__dirname, "index.html"));
);
app.get("/script", (param, response) =>
   response.sendFile(path.join(__dirname, "Client.js"));
);
app.post("/hello", (request, response) =>
   response.send({
       message: "Hello, " + request.body.name
   });
);
app.listen(4000, param => console.log("Server running on port 4000."));
exports.app = app;

To implement this safely in ReScript, we’ll need to have bindings for all the node and Express APIs that we use. As with the client code, this time we will write all the bindings from scratch.

First off, let’s define a type for our app, and bind to the express module:
type app
@module external express: unit => app = "express"
Using the @module annotation allows us to bind to the entire module and use it as a function that returns a value of type app, as such:
let app: app = express()
We’ll also need to bind to a function in the express module for the JSON middleware express.json(). To do that, we’ll create a middleware type for this value. Binding to a function in a module also uses @module annotation, with some extra information specifying which module we’re binding to:
type middleware
@module("express") external json: unit => middleware = "json"
To use the middleware, we need to call app.use. This is a regular method on the app object, so we’ll bind to it using @send:
@send external use: (app, middleware) => unit = "use"

With that binding, the ReScript call app->use(json()) compiles to app.use(express.json()).

Besides use, there are a couple more methods on the app object: get, post, and listen. We’ll write bindings for those in the same way as we did for use. We’ll also declare two new types to represent the request and response:
type request
type response
@send external get: (app, string, (request, response) => unit) => unit = "get"
@send external post: (app, string, (request, response) => unit) => unit = "post"
@send external listen: (app, int, unit => unit) => unit = "listen"
The request has a body property that we need to access:
@get external getBody: request => 'a = "body"
It will be used like this:
app->post("/hello", (request, response) => {
 let body = request->getBody
 ...
})
The response has send and sendFile methods. For send, we use 'a to allow us to send back arbitrary JSON payloads:
@send external sendFile: (response, string) => unit = "sendFile"
@send external send: (response, 'a) => unit = "send"

As a more type-safe alternative, we could also write a binding that accepts a specific type for the payload, and share that type with the client.

Finally, we’ll write bindings for node’s file system operations: __dirname and path.join().

We can model dirname as a regular external value:
@val external dirname: string = "__dirname"

Normally, path.join() is a variadic function that takes in an arbitrary number of strings. ReScript doesn’t have variadic functions by default, but we can model variadic functions in our bindings by making the argument a single array of strings and using the @variadic annotation.

At runtime, the elements of the array will get unpacked and passed into the variadic function we are binding to – this is how Js.logMany([a, b, c]) unwraps to console.log(a, b, c).

We’ll also use the @module annotation the same way we did for express.json():
@module("path") @variadic
external join: array<string> => string = "join"

We can call our new binding with join([x, y, z]), which will be compiled to path.join(x, y, z).

Here’s an example of how we’ll use the join binding together with dirname and sendFile:
app->get("/", (_, response) => {
 response->sendFile(join([dirname, "index.html"]))
})
The compiled output unwraps the variadic arguments as expected:
app.get("/", (function (param, response) {
       response.sendFile(Path.join(__dirname, "index.html"));
     }));

Aside, in this case since we only need path.join to join two strings, it would have also been acceptable to bind it as a regular function with two parameters.

The final result for our server code looks something like this:
type app
type middleware
type request
type response
@module external express: unit => app = "express"
@module("express") external json: unit => middleware = "json"
@send external use: (app, middleware) => unit = "use"
@send external get: (app, string, (request, response) => unit) => unit = "get"
@send external post: (app, string, (request, response) => unit) => unit = "post"
@send external listen: (app, int, unit => unit) => unit = "listen"
@get external getBody: request => 'a = "body"
@send external sendFile: (response, string) => unit = "sendFile"
@send external send: (response, 'a) => unit = "send"
@val external dirname: string = "__dirname"
@module("path") @variadic
external join: array<string> => string = "join"
let app = express()
app->use(json())
app->get("/", (_, response) => {
 response->sendFile(join([dirname, "index.html"]))
})
app->get("/script", (_, response) => {
 response->sendFile(join([dirname, "Client.js"]))
})
app->post("/hello", (request, response) => {
 let body = request->getBody
 response->send({
   "message": "Hello, " ++ body["name"],
 })
})
app->listen(4000, () => {
 Js.log("Server is running on port 4000.")
})

Again, aside from the bindings, the code should look very similar to JavaScript.

Running the Demo

To run the code locally, simply compile the project and run Server.bs.js using node to start the web server. Then, open up localhost:4000 on your web browser, and you should see the web page.

A screenshot of the localhost 4000 web page exhibits the rescript webapp demo. A text box to enter the name and a submit button are exposed on the page.

Try typing your name and hitting the submit button, and see the results show up on screen. Congratulations, you’ve now built your first full-stack web application using ReScript!

A screenshot of the localhost 4000 web page exhibits the rescript webapp demo. The name, Danny is entered inside the text box and the result appears on the screen, it reads, Hello, Danny.

In the next version, we’ll replace our handwritten bindings with some prewritten bindings. Generally, if good prewritten bindings exist for a particular library, you should use them since it saves a lot of time. Handwriting bindings for everything is quite time-consuming, but it’s sometimes necessary if there are no existing bindings, or if the existing bindings are not ergonomic for your specific use case.

Version 2: Using Imported Bindings

Now, let’s reimplement our web app using some third-party bindings from rescript-webapi, rescript-nodejs, and rescript-express. First, install the bindings from npm:
npm install rescript-express
npm install rescript-webapi
npm install rescript-nodejs
We’ll also need to add these dependencies under bs-dependencies in our project’s bsconfig.json:
...
 "bs-dependencies": [
   "rescript-express",
   "rescript-webapi",
   "rescript-nodejs"
 ],
...

Client

Let’s modify the client-side code to use external bindings.

First, we’ll use destructuring to extract the functions we want from the binding modules. For the Webapi.Dom.HtmlInputElement module, we will also assign aliases to the generically named ofElement and value functions, so that our code is easier to read. If we were working with other element types, this aliasing would have been necessary to avoid shadowing:
let {document} = module(Webapi.Dom)
let {getExn, flatMap} = module(Belt.Option)
let {getElementById} = module(Webapi.Dom.Document)
let {addEventListener, setInnerHTML} = module(Webapi.Dom.Element)
let {ofElement: getInput, value: inputValue} = module(Webapi.Dom.HtmlInputElement)

The code that selects our DOM elements should look familiar. Each selection is piped into Belt.Option.getExn because getElementById returns an optional value (None if the element doesn’t exist). Since we know the element exists, we will forcibly unwrap it.

The getElementById function returns a generic value compatible with Webapi.Dom.Element. That module only supports operations shared across all DOM elements, so in order to support input-specific operations such as reading the input value, we need to construct a value compatible with Webapi.Dom.HtmlInputElement using Webapi.Dom.HtmlInputElement.fromElement:
let submit = document->getElementById("submit")->getExn
let result = document->getElementById("result")->getExn
let input = document->getElementById("name")->flatMap(getInput)->getExn
Building the payload of the request using WebApi.Fetch is different from how we did it by hand; the API is much more structured and provides named parameters to ensure the request is well-formed:
submit->addEventListener("click", _ => {
 open Webapi.Fetch
 let payload = RequestInit.make(
   ~method_=Post,
   ~headers=HeadersInit.make({
     "Accept": "application/json",
     "Content-Type": "application/json",
   }),
   ~body=BodyInit.make(
     Js.Json.stringifyAny({
       "name": input->inputValue,
     })->getExn,
   ),
   (),
 )
 ...
}
Finally, we use Webapi.Fetch.fetchWithInit API to make the request. Like our handwritten binding, this API also returns a promise, and the result handling is similar to before:
 open Js.Promise
fetchWithInit("/hello", payload)
->then_(Response.json, _)
->then_(json => json->Js.Json.decodeObject->getExn->resolve, _)
->then_(obj => {
  let message = Js.Dict.get(obj, "message")->flatMap(Js.Json.decodeString)->getExn
 result->setInnerHTML(message)
 resolve()
}, _)
->ignore
The completed final version of our client-side code should look like this:
let {document} = module(Webapi.Dom)
let {getExn, flatMap} = module(Belt.Option)
let {getElementById} = module(Webapi.Dom.Document)
let {addEventListener, setInnerHTML} = module(Webapi.Dom.Element)
let {ofElement: getInput, value: inputValue} = module(Webapi.Dom.HtmlInputElement)
let submit = document->getElementById("submit")->getExn
let result = document->getElementById("result")->getExn
let input = document->getElementById("name")->flatMap(getInput)->getExn
submit->addEventListener("click", _ => {
 open Webapi.Fetch
 let payload = RequestInit.make(
   ~method_=Post,
   ~headers=HeadersInit.make({
     "Accept": "application/json",
     "Content-Type": "application/json",
   }),
   ~body=BodyInit.make(
     Js.Json.stringifyAny({
       "name": input->inputValue,
     })->getExn,
   ),
   (),
 )
 open Js.Promise
 fetchWithInit("/hello", payload)
 ->then_(Response.json, _)
 ->then_(json => json->Js.Json.decodeObject->getExn->resolve, _)
 ->then_(obj => {
   let message = Js.Dict.get(obj, "message")->flatMap(Js.Json.decodeString)->getExn
   result->setInnerHTML(message)
   resolve()
 }, _)
 ->ignore
})

Like the previous iteration, we’ll also need to bundle it using esbuild.

Server

On the server side, the Express module from rescript-express provides most of the server bindings, while NodeJs module from rescript-nodejs provides the file system API.

At the top of our file, we’ll destructure the Express module and extract the functions we need:
let {
 expressCjs: express,
 jsonMiddleware: json,
 listenWithCallback: listen,
 use,
 get,
 post,
 body,
 sendFile,
 send,
} = module(Express)

Since the whole file deals with Express server logic, it would have also been acceptable to just open Express at the top of the file.

Like we did for our client code, we can use aliases to give our imported functions better names: express instead of expressCjs and json instead of jsonMiddleware. By explicitly specifying the names, it’s easier to avoid accidental shadowing, and we can rename awkwardly named API functions to better match the API name in JS.

Converting the endpoints to use the new APIs should be pretty straightforward:
app->get("/script", (_, response) => {
 open NodeJs
 response->sendFile(Path.join([Global.dirname, "Client.js"]))->ignore
})
After all the API call sites have been updated, the server code for our final version should look like this:
let {
 expressCjs: express,
 jsonMiddleware: json,
 listenWithCallback: listen,
 use,
 get,
 post,
 body,
 sendFile,
 send,
} = module(Express)
let app = express()
app->use(json())
app->get("/", (_, response) => {
 open NodeJs
 response->sendFile(Path.join([Global.dirname, "index.html"]))->ignore
})
app->get("/script", (_, response) => {
 open NodeJs
 response->sendFile(Path.join([Global.dirname, "Client.js"]))->ignore
})
app->post("/hello", (request, response) => {
 let body = request->body
 response
 ->send({
   "message": "Hello, " ++ body["name"],
 })
 ->ignore
})
let _ = app->listen(4000, _ => {
 Js.log("Server is running on port 4000.")
})

And now we’re done! The new code leverages third-party bindings to allow us to safely use JavaScript libraries, without spending all day writing our own bindings.

Final Thoughts

You’ve learned how to prototype a full-stack web application in ReScript; write your own bindings for functions, objects, and modules; and also use bindings that other people have written.

The way that ReScript works gives a lot of flexibility in how we can use it. Writing a web application from scratch entirely in ReScript (like we just did) is just one way of using this language.

It’s possible to integrate ReScript into an existing JavaScript or TypeScript application by writing a self-contained module in ReScript, or by incrementally rewriting parts of the old code base in ReScript. There are official guides to gradually adopt ReScript and integrate it into existing applications, and if you follow them, the migration process should be smooth and relatively nondisruptive.

While I don’t provide any examples of migrating large code bases in this book, I recommend that you try it out if you have an old JavaScript or TypeScript project lying around. You’ll get valuable practice using all of ReScript’s nice features and get a better sense for how ReScript can help make web applications less buggy and safer to develop. Once the process is over, you’ll have more peace of mind, thanks to the additional safety guarantees that ReScript provides.

It’s also worth mentioning that although the front end of our demo was just a simple HTML page, it’s possible to use ReScript with a number of common front-end web frameworks – in particular, readers intending to use ReScript for front-end web development will be delighted to learn that ReScript has excellent first-class support and integration with React, including the ability to write JSX directly in ReScript files. React is out of scope for this book, but you can find details on ReScript’s first-class bindings for React and JSX in the official documentation.

Lastly, the functional programming concepts in this book don’t just apply to programming in ReScript. Even when working in a language without features like type inference and pattern matching, we can still think about programming in terms of immutability, composability, and side effects to make our code cleaner, safer, and more maintainable.

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

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