ReScript is a robustly typed language that compiles to efficient and human-readable JavaScript. It comes with a lightning fast compiler toolchain that scales to any codebase size. (from ReScript-Lang.org)
Introduction
Good timezone everyone! I’m Ilya, a fullstack web developer at Noveo and a big fan of functional programming. Today I’d like to introduce you to an underrated programming language called ReScript – you’ll see why I consider it underrated over the course of the presentation, but as a little spoiler, I’ll just say these two words: expressiveness and speed.
Development history
The origin of ReScript dates all the way back to 1996, and is associated with the creation of OCaml, a strongly and statically typed general-purpose language that was developed with an emphasis on expressiveness and security. The same philosophy was later applied to many other tools written in OCaml.
This language has been in use for many years in a lot of companies. One of the solutions you may know is Flow, a static code analyser for JavaScript. Or maybe you’ve heard of Hack – a programming language based on PHP, with static typing and its own runtime. In fact, Hack’s runtime was implemented using C++, whereas the whole type system was developed in OCaml.
Later on, it was decided to try and run the OCaml code in browsers – and thus, there appeared js_of_ocaml. I haven’t been able to find out when the development of js_of_ocaml started exactly, but apparently, the first commit was made on the 1st of February, 2010.
Six years since then, in 2016, Bloomberg published a new open-source project called BuckleScript – a backend for the OCaml compiler that emitted a pretty well optimised JavaScript code.
Example:
let sum n =
let v = ref 0 in
for i = 0 to n do
v := !v + i
done;
!v
BuckleScript will use this OCaml code to compile it to JavaScript:
function sum(n) {
var v = 0;
for (var i = 0; i <= n; ++i) {
v += i;
}
return v;
}
Around the same time, Facebook began working on Reason, a programming language that used the full power of the OCaml type system, but had a syntax very similar to JavaScript and other C languages. As a result, BuckleScript and Reason had been paired up and used together up until the teams split in 2019 – the Reason team then focused on native development, while the BuckleScript team rebranded their compiler and combined all of the BuckleScript and Reason tools into ReScript, thereby drastically improving the experience of using this new language.
ReScript vs TypeScript
Although both ReScript and TypeScript are used by developers to achieve similar goals, the languages themselves are quite different, with different kinds of possibilities and constraints.
- TypeScript is intended to be a superset of JS and fully support its syntax, whereas there’s no such goal for ReScript, as it only covers a small curated part of JavaScript. However, in addition to different types on top of standard JS methods (map, reduce, filter, some, etc.), ReScript also has its own standard library for handling data (Belt), which is not the case with TypeScript.
- ReScript has a very advanced type system, which doesn’t contain any ambiguities (aka type soundness), and also doesn’t require you to specify any types of values, because its intelligent type inference feature takes care of that for you. TypeScript, on the other hand, is not designed to ensure correct typing, and its type inference is not as great either.
Key advantages
Type soundness
The ReScript compiler ensures that any data that is held in variables within the system always matches the type assigned to it, provided that all required values have been declared within the system. However, if you need to work with data that comes from other sources, let’s say from a server, you’ll have to perform some extra checks to make sure that all data corresponds to the types declared by another developer. Thankfully, there are a couple of libraries that can be used in such cases.
Performance
- Compilation speed
- Inlining constants
- Highly optimised standard library (I mention some benchmarks further down in the article)
- Reanalyze
Advanced type inference
The first thing you notice when you start programming in ReScript is that the language compiler can – on its own – work out which type a function’s argument should have based on the context in which it is used. This greatly reduces the amount of boilerplate that needs to be written in order to make the type system work properly. I included a couple of highly simplified code snippets from an actual project of mine below, to give you an idea of how ReScript differs from TypeScript.
As you can see in Listing 1.1, everything works perfectly – the type system can fully handle the task on its own, without your having to assign types manually.
// Listing 1.1. ReScript vs TypeScript. Type inference - ReScript
// 01_type_inference/type_inference_example.res
type roomRecord = {
id: string,
body: string,
connected: bool,
}
let emptyRoom = {
id: "",
body: "",
connected: false,
}
let context = React.createContext((emptyRoom, _ => ()))
let useRoom = () => {
React.useContext(context)
}
let useConnect = () => {
let (_room, setRoomState) = useRoom()
room => setRoomState(room)
}
@react.component
let make = () => {
let connect = useConnect()
connect({id: "id", body: "body", connected: true})
<div>{React.string("Hello, world")}</div>
}
If you write the same kind of code in TypeScript (Listing 1.2), its type system will “stumble over” two different spots at once:
- Room parameter has an any type,
- The type of the context variable is inferred incorrectly; due to that, setRoomState cannot be called because not all constituents of the automatically inferred type of the context variable are callable.
// Listing 1.2. ReScript vs TypeScript. Type inference - TypeScript type errors
// 01_type_inference/type_inference_example-fail.tsx
import React from "react";
interface Room {
id: string;
body: string;
connected: boolean;
}
const room: Room = {
id: "asdf",
body: "asdf",
connected: false
}
const context = React.createContext([room, (_) => {}]) // React.Context<(Room | ((_: any) => void))[]>
function useRoom() {
return React.useContext(context)
}
function useConnect() {
const [_room, setRoom] = useRoom()
return (room) => // Parameter 'room' implicitly has an 'any' type.
setRoom(room) // This expression is not callable.
// Not all constituents of type
// 'Room | ((_: any) => void)' are callable.
}
export function Component() {
const connect = useConnect()
connect({id: "id", body: "body", connected: true})
return <div>Hello, world</div>
}
In order for TypeScript to understand what’s going on and start helping you out, you will first need to help TypeScript, by intentionally specifying the types of all variables that it couldn’t interpret correctly on its own (Listing 1.3).
// Listing 1.3. ReScript vs TypeScript. Type inference - TypeScript manually assigned types
// 01_type_inference/type_inference_example-success.tsx
import React from "react";
interface Room {
id: string;
body: string;
connected: boolean;
}
const room: Room = {
id: "asdf",
body: "asdf",
connected: false
}
const context: React.Context<[Room, (room: Room) => void]> =
React.createContext([room, (_) => {}])
function useState () {
return React.useContext(context)
}
function useConnect() {
const [_room, setRoom] = useState()
return (room: Room) => setRoom(room)
}
export function Component() {
const connect = useConnect()
connect({id: "id", body: "body", connected: true})
return <div>Hello, world</div>
}
The sharpness of the ReScript type system is also apparent when you take a look at the pattern-matching exhaustiveness check, which I’ll cover a bit later in the “Pattern matching” section.
No null
In his 2009 presentation “Null References: The Billion Dollar Mistake”, Tony Hoare, the creator of the null reference, said:
“I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object oriented language (ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.”
Luckily, these days there exists a more elegant solution – a separate type called option, which, at the type system level, explicitly states that variables may or may not contain data.
type option<'a> = None | Some('a)
Essentially, in cases where in JS you would normally use null or undefined, here in ReScript you should use None, and later on the ReScript type system will always prompt (or force, depending on your compiler’s settings) you to check whether all of the variables of the option type hold any data in them.
// Listing 2.1. Option type, without Null Reference
// 02_option_type/option_example.res
let getSomeData = path => {
if path == "some/path" {
Some("data")
} else {
None
}
}
@react.component
let make = () => {
let (dataOption, _setDataOption) = React.useState(() => getSomeData("path"))
let Some(_data) = dataOption
switch dataOption {
| Some(data) => <div>{React.string(data)}</div>
| None => <div>{React.string("No data")}</div>
}
}
Pattern matching
Pattern matching is a very powerful tool, which I also consider to be one of the most expressive and useful constructions in the language.
In its most basic variants, pattern matching resembles the well-known destructuring in JavaScript:
// Listing 3.1. Elementary example of tuple destructuring
// 03_pattern_matching/example_01.res
let boxSizes = (10, 20, 30)
let (height, width, depth) = boxSizes
Js.log(height) // 10
Js.log(width) // 20
Js.log(depth) // 30
On top of that, you can also destructure variables directly within switch, which is impossible to do in JS:
// Listing 3.2. Destructuring within Switch
// 03_pattern_matching/example_02.res
type animal = Dog | Cat | Bird
let isBig = true
let myAnimal = Dog
let categoryId = switch (isBig, myAnimal) {
| (true, Dog) => 1
| (true, Cat) => 2
| (true, Bird) => 3
| (false, Dog | Cat) => 4
| (false, Bird) => 5
}
Js.log(categoryId) // 1
But pattern matching isn’t just about destructuring – it makes it possible for you to write even more expressive constructions as well.
// Listing 3.3. Processing data options
// 03_pattern_matching/example_03.res
type payload =
| BadResult(int)
| GoodResult(string)
| NoResult
let data = GoodResult("Product shipped!")
switch data {
| GoodResult(theMessage) =>
Js.log("Success! " ++ theMessage)
| BadResult(errorCode) =>
Js.log("Something's wrong. The error code is: " ++ Js.Int.toString(errorCode))
| NoResult =>
Js.log("Bah.")
}
// Listing 3.4. Processing variants with nested objects
// 03_pattern_matching/example_04.res
let services = switch user {
| Programmer({grade: "teamlead"}) => [Gitlab(...),Jira(...),Confluence(...),Special]
| Programmer({grade: "techlead"}) => [Gitlab(...),Jira(...),Confluence(...),Special]
| Programmer({grade: "junior"}) => [Gitlab(...),Jira(...),Confluence(...)]
| Programmer(_programmer) => [Gitlab(...),Jira(...),Confluence(...)]
| Manager(_manager) => [Gitlab(...),Jira(...),Confluence(...)]
| Visitor(_visitor) => [Gitlab(...),Jira(...),Confluence(...)]
}
And here’s an example of code from one of my actual projects, in which destructuring makes things so much easier by enabling me to write the page management code in React in a noticeably clearer way.
// Listing 3.5. Routing processing in a React application
// using ReScript with the RescriptReactRouter library
// 03_pattern_matching/example_05.res
@react.component
let make = () => {
let url = RescriptReactRouter.useUrl()
<div>
{switch url.path {
| list{"profile", "my"} => <MyProfile />
| list{"profile", id} => <UserProfile id />
| list{"settings"} => <Settings />
| list{"marketplace", category, id} => <Marketplace category id />
| list{"play"} => <PlayScreen />
| list{} => <HomeScreen />
| _ => <p> {React.string("page not found")} </p>
}}
</div>
}
If you were to write the code using the now-standard ReactRouter, it would look something like this:
// Listing 3.6. Routing via react-router-dom
// 03_pattern_matching/example_06.res
function App() {
return (
<div>
<BrowserRouter>
<Switch>
<Route exact path="/">
<HomeScreen />
</Route>
<Route exact path="/profile/my">
<MyProfile />
</Route>
<Route path="/profile/:id">
<UserProfile id="demo" />
</Route>
<Route path="/settings">
<Settings />
</Route>
<Route path="/marketplace/:category/:id">
<Marketplace category="demo" id="demo" />
</Route>
<Route path="/play">
<PlayScreen />
</Route>
</Switch>
</BrowserRouter>
</div>
)
}
I’m sure you’d agree with me if I say that the code you can write in ReScript, combined with its own pattern matching and router implementation, looks a lot cleaner and more readable. Besides, the application’s performance is supposed to be much higher as well, at least because you don’t need to create BrowserRouter, Switch and Route components. According to the developers of the library, ReScript does not allocate memory in the heap in order to create routing, and can be compiled into a jump table via JIT.
Pattern matching also comes in handy when it comes to handling requests to the server and displaying the current state of such requests in the UI:
type fetchResult<'a, 'b> =
| Init
| Loading(option<'a>)
| Success('a)
| Failed('b)
@react.component
let make = () => {
let fetch = useFetch() // fetchResult
<div className="some-component">
{switch fetch.state {
| Init => <button onClick={fetch.call}>{React.string("Load data")}</button>
| Loading(Some(previousData)) => <SuccessComponent data=previousData isLoading=true />
| Loading(None) => <Loader/>
| Success(data) => <SuccessComponent data />
| Failed(error) => <FailedComponent error />
}}
</div>
}
Pipe operator
Chances are, at one point or another, you tried coding in JavaScript using functional style, but in doing so you inevitably dotted your work with loads of transitive variables that didn’t make much sense on their own.
function dedup(pages) {}
function rangify(pages) {}
function prettifyRanges(ranges) {}
function get_ranges(pages) { // [1,2,3,5,5,8,9]
const deduplicatedPages = dedup(pages); // [1,2,3,5,8,9]
const ranges = rangify(deduplicatedPages); // [[1, 3], [5, 5], [8, 9]]
return prettifyRanges(ranges); // "1-3, 5, 8-9"
}
function get_ranges(pages) {
return prettifyRanges(rangify(dedup(pages)))
}
Here’s the same example but written in ReScript:
let dedup = pages => ...
let rangify = pages => ...
let prettifyRanges = ranges => ...
let buildQuery = pages => {
pages // [1,2,3,5,5,8,9]
->dedup // [1,2,3,5,8,9]
->rangify // [(1, 3), (5, 5), (8, 9)]
->prettifyRanges // "1-3, 5, 8-9"
}
let buildQueryWithoutPipe = pages =>
prettifyRanges(rangify(dedup(pages)))
I think now it’s pretty clear that, compared to the first piece of code, the second one is much easier to read – and it’s all thanks to the pipe operators.
But, in case you’re still not convinced that pipe operators are quite convenient and should be used more, I’ve got one more argument up my sleeve: there is a proposal in tc39 “for adding a useful pipe operator to JavaScript”, and as of March 16, 2022, its draft is in Stage 2. Below you can see an example given by the authors of the proposal:
console.log(
chalk.dim(
`$ ${Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`).join(' ')}`,
'node',
args.join(' ')));
Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ')
|> `$ ${%}`
|> chalk.dim(%, 'node', args.join(' '))
|> console.log(%);
function dedup(pages) {}
function rangify(pages) {}
function prettifyRanges(ranges) {}
function get_ranges(pages) { // [1,2,3,5,5,8,9]
const ranges =
dedup(pages) // [1,2,3,5,8,9]
|> rangify(%) // [[1, 3], [5, 5], [8, 9]]
|> prettifyRanges(%) // "1-3, 5, 8-9"
return ranges
}
function get_ranges(pages) {
return prettifyRanges(rangify(dedup(pages)))
}
Although the draft is still fairly new and the syntax may be changed in the future, you can already see why the pipe operator is a really neat feature for quickly chaining multiple function calls or data transformations together.
No imports, no exports
Do you also find it annoying when you see a whole list of all kinds of different imports, with no apparent order and/or correlation, at the top of a .js file?
Let’s say, there’s a Page component with imported components, hooks, functions and so on:
import GenericTemplate from "../../templates/GenericTemplate/GenericTemplate";
import Footer from "../../organisms/Footer/Footer";
import Header from "../../organisms/Header/Header";
import Hero from "../../organisms/Hero/Hero";
import MainMenu from "../../organisms/MainMenu/MainMenu";
import {sortByKingdom} from "../../../../utils"
import TodoList from "../../organisms/TodoList/TodoList";
import useFetch from "../../../../hooks/useFetch"
Even using aliases and linters doesn’t seem to make much of a difference.
import Footer from "src/components/organisms/Footer/Footer";
import Header from "src/components/organisms/Header/Header";
import Hero from "src/components/organisms/Hero/Hero";
import MainMenu from "src/components/organisms/MainMenu/MainMenu";
import TodoList from "src/components/organisms/TodoList/TodoList";
import GenericTemplate from "src/templates/GenericTemplate/GenericTemplate";
import useFetch from "src/hooks/useFetch"
import {sortByKingdom} from "src/utils"
Even if you refactor all these imports, later on your teammate might add a new button somewhere on the page and, once again, import it – various conflicts are bound to happen here and there, and nobody really likes to deal with that.
So why not simply get rid of imports within the framework of your project completely? The creators of ReScript figured just as much and designed a system of modules visible throughout the whole project:
// src/components/Button.res
@react.component
let make = (~title, ~onClick) => {
<button onClick> {React.string(title)} </button>
}
// src/components/SpecialButton.res
let handleClick = _ => Js.log("Clicked!")
@react.component
let make = (~title, ~body) => {
<div>
<h1> {React.string(title)} </h1>
<p> {React.string(body)} </p>
<Button onClick=handleClick title="Button" />
</div>
}
GenType
Let’s imagine the following situation: you work on a number of projects with several different teams, and all of them use different languages. Some developers write in JavaScript, some in TypeScript, and some might even use Flow. And so, you get an idea to create a library that could be used across all your projects – but, you really couldn’t be bothered to deal with all of the types in every single one of those languages. That’s exactly where GenType comes in handy: the very purpose of this tool is to generate all the necessary types so that you can easily use your library directly from JS, TS or Flow.
Sadly, in my own work, I haven’t been able to benefit from GenType that much, but it does look very cool in theory.
Extensive standard library
You have full access to all methods and features of the standard JavaScript library right out of the box, but on top of that, ReScript also provides you with its own standard library called Belt. It offers a variety of data structures, both mutable and immutable. In fact, Belt is pretty similar to the libraries I’ve seen in other functional programming languages. It’s actually quite nice, but on the downside, there’s no functionality for handling strings and dates as it hasn’t been implemented yet – so, for now you can use standard JavaScript methods from the Js.String2 and Js.Date2 modules.
Belt is extremely easy to use, plus, it’s designed in a way that ensures maximum security from out-of-the-box. Moreover, it provides a wider range of options for working with different kinds of collections, in contrast to the standard JS library (if we can call it that).
In addition to arrays, you have linked lists, hash maps, ranges, sets and other useful structures at your disposal. Some structures are quite generic, while others are specifically optimised to handle two types of keys, namely strings and integers.
Performance: Belt vs JavaScript
Whenever I use libraries (such as Lodash) instead of standard JS functionalities, I always wonder about the impact this solution might have on the app’s execution time or, for example, on the size of the bundle. This is why I set myself a goal to measure the performance of some of the Belt functions, and then to examine the changes in the speed of execution of some basic code. Can you imagine how surprised I was when I realised that not only did these library functions keep up with the standard methods, but they actually surpassed them!
I can’t tell you the exact reason why it happened because I wouldn’t want to mislead anyone – including myself; if anything, you can always try and find more information on Google or take a look at the V8 source code. However, one thing is for certain: in terms of performance in Node.js – and therefore, in Chromium-based browsers – Belt will come out a winner in the majority of cases.
Belt.Array.mapU(arr, fn)
arr.map(fn)
_.map(arr, fn)
R.map(fn, arr)
Belt.Array.joinWithU(arr, "", fn)
arr.join("")
_.join(arr, "")
R.join("", arr)
Belt.Array.reduceU(arr, initialValue, fn)
arr.reduce(fn, initialValue)
_.reduce(arr, fn, initialValue)
R.reduce(fn, initialValue, arr)
Belt.Array.keepU(arr, fn)
arr.filter(fn)
_.filter(arr, fn)
R.filter(fn, arr)
Belt.SortArray.stableSortInPlaceByU(arr, fn)
arr.sort(fn)
R.sort(fn, arr)
Here are the results of my performance analysis, conducted locally via the Benchmark.js library on Node.js (of course, the more data the better):
Testing was done on Node.js v16.14.0, Windows WSL2/Ubuntu, Ryzen 5800H, 16GB.
JS interop
One of the main differences between ReScript and something like Elm is that the former allows you to fully – and relatively easily – interact with the JavaScript code. The only thing you need for that is the right interface that will let you work with external code. ReScript provides a huge number of attributes for tackling the task at hand: new, scope, send, module, val and many others. Below you can see the code from the rescript-uuid library that I’ve created – it implements ReScript bindings for the well-known JavaScript library called uuid.
type bytesT = (int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int)
module Uuid = {
module V4 = {
@deriving(abstract)
type options = {
@optional random: bytesT,
@optional rng: unit => bytesT,
}
@module("uuid") external make: unit => string = "v4"
@module("uuid") external makeWithOptions: (~options: options) => string = "v4"
@module("uuid")
external makeWithBuffer: (
~options: options=?,
~buffer: array<int>,
~offset: int=?,
unit,
) => string = "v4"
}
}
/**
* UUID V4
*/
let uuidv4_1 = Uuid.V4.make()
let uuidv4_2 = Uuid.V4.makeWithOptions(~options=Uuid.V4.options())
let uuidv4_3 = Uuid.V4.makeWithBuffer(~options=Uuid.V4.options(), ~buffer=[], ~offset=0, ())
// Generated by ReScript, PLEASE EDIT WITH CARE
'use strict';
var Uuid = require("uuid");
var V4 = {};
var Uuid$1 = {
V4: V4
};
var uuidv4_1 = Uuid.v4();
var uuidv4_2 = Uuid.v4({});
var uuidv4_3 = Uuid.v4({}, [], 0);
exports.Uuid = Uuid$1;
exports.uuidv4_1 = uuidv4_1;
exports.uuidv4_2 = uuidv4_2;
exports.uuidv4_3 = uuidv4_3;
/* uuidv4_1 Not a pure module */
Disadvantages
Here’s a list of some of the major difficulties that I’ve faced, and that I think may also be an issue for other developers when they switch over to ReScript:
- Rather small community
- Not a lot of libraries
- Functional programming is known to have quite a high barrier to entry
- Shortage of learning resources
- It is not that easy to find people who have experience of working with ReScript and/or are willing to use it in their projects
- For the most part, you become tied to one single framework (React)
Conclusion
Lastly, I’d like to point out that no modern-day technology can provide a solution to all of our problems. While for some projects the strictness and correctness of the ReScript type system may prove to be the most crucial factor, for others the top priority may be given to the possibility to use whichever npm package you like without having to write – and maintain – any types just so that you can interact with certain functionalities within your project. However, if you need to implement some critical functionality, with strict requirements in regards to the correctness of code – and then distribute it across other projects that use, let’s say, both TypeScript and Flow – then perhaps you should take a closer look at ReScript.