ReScript for React Development

ReScript for React Development

ReScript for React Development


Intro

Frontend development is a bustling place, with a lot of new technologies coming in and out. During the last decade, we have seen a definite shift in the way web pages are developed. We no longer have to touch DOM API directly or play around with awkward Angular dependency injection. Currently, we heavily use different build tools to include assets in our code, process CSS, and so on.

We build FE applications from dozens of files, and we use bundlers to produce production code for us. This makes us compile our codebase in the end product, and it’s pretty similar to what typed languages do. But there is one big difference : we use JavaScript, a super flexible but very error-prone language without any types. With file compilation (or rather bundling & transpiling for TypeScript), nothing stops us from replacing JavaScript with something that can produce JavaScript.

There are many ideas on the market on how to implement a type system for frontend development. To mention just a few:

TypeScript, ClojureScript, PureScript, Scala.JS, Elm, JS_of_ocaml, ReScript, or even write frontend in C or Rust thanks to WebAssembly and JS bindings (like wasm-bindgen).

In this article, I want to highlight the development & business advantages of ReScript. I hope after the lecture, you will have a list of arguments and counterarguments that will help you make up your mind what technology you should use for your application.

TLDR;

JavaScript’s lack of a type system makes it an excellent tool for fast prototyping. At the same time, the lack of types makes every change at risk of breaking code in runtime. A more extensive codebase will make the error scale larger, which badly impacts maintenance and time to market. A strongly typed language like ReScript helps address these issues, which can have a positive influence on business and make development much smoother.

Apart from all that, ReScript:

  • compiles enormously fast
  • has dead code elimination – smaller production bundle size
  • Has syntax that is easy to understand for JS developers
  • is easy to implement in an existing project and integrates with JS codebase
  • has functional features that make development easier

ReScript features that help developers create code smoother:

  • pipe operators
  • currying by default
  • variant types that can carry value & pattern matching
  • everything is expression
  • immutable data structures by default (expect arrays)
  • modules (over objects)

On the cons side:

  • ReScript code formatting is a pretty unpleasant experience that makes code harder to read (hopefully, this may change in the future).
  • Some great features from ReasonML are no longer valid in ReScript (such as a single argument fun function or deprecated pipes)
  • There is no Redux analog for ReScript. Building multiple stores, multiple sub-application, strongly typed software requires a notable amount of work.
  • Not really suitable if a project requires out-of-the-box support for many libraries with complex APIs.

Shiny Rescript advantages

#1 Safer development with type system guards

In my first years as a JavaScript developer, I didn’t understand the need for catching bugs at compile-time. Building a JavaScript application is quite fast, especially with hot module replacement. A developer can almost immediately manually test results in a browser. There are several dev tools to track errors in JavaScript runtime. As an application gets more extensive, a path to spot bugs becomes longer, and catching them gets harder and harder.

With a type system, a developer has immediate feedback from the compiler with a descriptive error message.

A TypeScript type system in some ways addresses these concerns. However, it doesn’t promise to solve them. I have written TypeScript for a few years now, and I was convinced that it’s better to have some kind of type system than none at all. That is until I got my hands on Rust language. Rust is a very strongly typed language, making you almost 100% sure that it will run if the code compiles. TypeScript is far from that, so I wanted a similar experience to Rust on the front end. I found ReScript (ReasonML then) and decided that it’s better to have a good type system than just any.

ReScript is also a strongly typed language and contains a powerful OCaml type system that can interfere in most cases.

I’m going to briefly compare ReScript and TypeScript instead of JavaScript because

  1. There is no static type system in JavaScript.
  2. TypeScript is the broadest used typed language for the frontend at the moment.

The most significant advantages of ReScript over TypeScript:

There is only one way to define object equivalent with record

type test = {
    name: string
}

and multiple potential ways in TypeScript:

type Test = {
    name: string
};

interface Test2 {
    name: string
}

class Test3 {
    name: string;
    constructor(value: string){
        this.name = value;
    }
}

You should use 3 of these in different situations. But TS is built to allow all of them, making things a bit confusing.

Also, types are not only for a compiler. They are also for documenting code. With TypeScript, building abstraction can lead to losing type checking on the definition site while still getting it on-call site. This gives the compiler guarantees but can be less descriptive and more confusing to debug. Without explicitly passing types and using all of the 3 examples provided above, it might require some effort to understand which type is actually used.

In ReScript, everything has its type

All variables, records, functions, and even modules have type in ReScript. This means the compiler always tells you when you try to pass something where it doesn’t fit. TypeScript, in contrast, has something called any, which defeats the whole point of the type system. TypeScript newcomers frequently overuse any when the interface doesn’t fit the current requirements. This takes the compiler guards down and signals that there might be something wrong with the code structure if implementing type is too hard.

It’s possible to configure TypeScript to not accept any, but <em>tsconfig</em> itself only allows disabling implicit use. You will need a linter configuration to disallow <code>any. But this will end up with a different type of error (linting error instead of compiler error) and will disallow implementing abstraction on some level.

No null or undefined in ReScript

The type system doesn’t solve every problem. It won’t fix logic bugs in our code, but it can help with them in a few cases. Null and undefined often can sneak into JavaScript code and cause unexpected behavior. TypeScript doesn’t solve the problem either. ReScript, in contrast, doesn’t allow undefined or null values. There must always be the correct type. For situations when there might be no value, or some expression can return an error, ReScript uses variant types. Usually, option (Haskell Maybe) & result carry a value, information about its absence or error. Moreover, the ReScript compiler will force us to cover negative scenarios.

This is an absolute switch in thinking about code and forces us to avoid logic that might end with unexpected behavior.

ReScript variant types are very similar to Rust enum. If you would like to learn more, I recommend this Rust documentation on that subject.

Variant types are also a fantastic way to build our reducer and actions, as the syntax is much more compact than TS conditional types.

Superior type inference

TypeScript does have type inference but highlighting the previous points, the existence of any, undefined, & null developers often have to be explicit about type declaration.

In addition, it doesn’t always work well for interfering anonymous functions, and it can be cumbersome when defining the type signature of parameters.

In contrast, the ReScript compiler is excellent at inferring types and does it in most cases. This means fewer places to declare types. This means less boilerplate code which means more pleasant development. All of this is still within the borders of compiler guards.

All these points may not sound like much, but combined make a huge difference in practice. While TypeScript tries to charm developers with type systems, ReScript forces types and guards users against a lot of runtime errors. With no any and types enforcement, this is a mental switch for JavaScript developers to “type-based development.”

#2 Fast compilation time & Dead code elimination

TypeScript 3 is getting faster but is still behind ReScript. The ReScript compilation is speedy. For small and medium-size code bases, it’s usually a matter of milliseconds. For extensive applications, it may take seconds, but the compiler responds almost immediately with success or errors.

I have to mention that ReScript output is not our production bundle. The ReScript compiler returns JavaScript files corresponding to the original ones. This means we still need to bundle ReScript output with a tool such as Webpack.

Thankfully our output is clearly readable JS files without JSX. This makes bundling faster and JSX translation unnecessary. In practice, Webpack Dev Server with hot module replacement reloads the webpage so quickly it might even be unnoticed while switching between applications or workspaces.

JavaScript output files are also cleaned of any unused code.

#3 Easy to implement in an existing project

There are several things that make ReScript easy to add to an existing project.

  1. While working with ReScript, you’re most likely going to work with ReScript-react. This is not a new framework. Instead, it provides bindings for React, and as I mentioned before, ReScript will output you .res components in .JS version. This means you can use a single React runtime in the production bundle.
  2. You can use the genType library to output ReScript types to TypeScript types or use TS types in your code.
  3. Thanks to ReScript bindings, it’s relatively easy to use existing JavaScript and TypeScript codebase. It’s also effortless to wrap existing JS libraries with ReScript bindings.
  4. Thanks to bindings and JavaScript output, it’s easy to use tools such as webpack to manage style preprocessors or asset loaders.
  5. ReScript syntax is very similar to JavaScript syntax and should be very easy to grasp for JS developers.

#4 Easier development with a functional flavor

You probably associate “functional programming” with pure functions, managing side effects, declarative programming, functors, monads, etc. I don’t want to go deep into these details because there are already plenty of absolutely fabulous materials about FP (Haskell book, scala courses, etc.).  Also, I don’t want to convince you into purely functional development. I simply want to highlight how ReScript’s practical flavor compared to strictly FP languages can positively influence development.

TypeScript is not a functional language. In fact, it’s almost like duck typed Java for the browser. It doesn’t stop you from implementing functional features, but it makes things significantly more demanding, resulting in complex code and overengineering. It makes things harder, while functional programming is supposed to make things simpler!

Moreover, you can try to implement Maybe monad (ReScript option) or use awkward union types, introduce currying or force immutability with libraries. But still, TypeScript/JavaScript syntax will be your enemy.

For me, functional programming is thinking about data flow instead of what variables or objects to declare.

ReScript gives us reliable tools to deal with functional code, and I’m going to briefly describe the most significant way to make writing code a smooth and pleasant experience.

Pipe operator

One of the most significant issues with functions in JavaScript & TypeScript is how to pass one function result to another.

Without a pipe operator, there are 3 options:

1. Assign every return value to a variable:

let res1 = run1(value);
let res2 = run2(res1);

This can end up with a lot of unnecessary assignments. With many variables, we can easily skip thinking about our data and spend most of the time managing names. This also results in more imperative code.

2. Wrap function execution with another function execution:

run4(run3(run2(run1(value))));

This can quickly get out of hand, adding more brackets. It’s also hard to refactor, and the order of execution is from right to left, which might be a bit confusing and not intuitive.

3. Returning object with methods:

class Obj1 {
    value: number;
    constructor(value: number) {
        this.value = value;
    };
    public addOne() {return this.value + 1};
};
class Obj2 {
    static multiplyByTwo(value: number) {return new Obj1(value * 2)}
};
Obj2.multiplyByTwo(2).addOne();

It’s a common approach to chain operations without a pipe operator. This approach has at least a few downsides.

Firstly, it requires defining the structure we want to return (obj1 in our example). This means we can’t focus on the function. We need the whole chain at once.

Secondly, it’s not really obvious what multiplyByTwo returns until we look into the Obj1 definition.

Thirdly, using this meaning, we don’t use pure functions because we use object context that might change, and we can change it with mutating object property. This is an area for a lot of potential bugs and hard debugging.

With pipe operator:

value
    -> MathOperation.multiplyByTwo
    -> MathOperation.addOne

This makes things much more manageable. Functions are pure and straightforward. In addition, the execution order is intuitive. Thanks to qualified imports (by importing and using the whole module ‘MathOperation’ instead of functions multiplyByTwo & addOne), it’s clear what functions originate and the associated functionality.

Unfortunately, after rebranding from ReasonML to ReScript, the core team decided to deprecate triangle pipe |> and use only -> .

|> pass our value as the last argument while -> gives it as first. The problem is that -> syntax is a bit unfortunate, in my opinion, as the arrow is also used in many languages to describe type annotation.

Curry by default

In ReScript, every function you define is curried by default. This means every time you didn’t pass all the arguments, the function returns a function that accepts the rest of the arguments as its parameters.

Simple example:

let add = (a,b) => a + b;
add(2,2); // returns 4
let addTwo = add(2); // function that adds 2 to passed argument
addTwo(3); // returns 5

Okay, that’s kind of basic, so let’s see a few more live examples.

Let’s say we have:

  • a list of elements, for simplicity it will be a list of strings
  • a variable that stores a selected piece, a string in our example

We want to map the list to React components and highlight the selected.

First:

Function to check if an element is selected and add a css class if it is:

let selectedOrNot = (value, isSelected) => 
    isSelected == value 
        ? <p className="selected">{React.string(value)}</p>
        : <p>{React.string(value)}</p>;

and in JSX we can do:

<div>
    {
        ourList
            -> List.map(selectedOrNot(selectedValue))
            -> ReScriptReact.list;
    }
</div>

Thanks to currying, we have used the captured value in closure every time List.map runs callback on the list elements.

This is still a simplistic example, but I hope it helps draw a better picture of how currying can be helpful and how powerful it is combined with piping.

Variant types and pattern matching

I first encountered a similar structure in Rust with its enum. Once learned, it’s hard to live without it. Variant types are helpful in several situations. As mentioned before, option and result help dealing with non-existing values or errors in a typed fashion. Variant types might be a handful of defining custom errors that later might be used as a second type parameter for result:

type inputError = 
    | BadInputType
    | EmptyInput
    | ParsingError(string);

Then the result type may have an interface: result<string, inputError>.

Inexplicably, most online tutorials for Redux + TypeScript use something like this to describe actions and reducers:

const MY_MAGIC_STRING_TYPE = "MY_MAGIC_STRING_TYPE";
cosnt exampleAction = value => ({type: MY_MAGIC_STRING_TYPE, value});
const reducer = (state, action) => {
    switch (action.type) {
        case: MY_MAGIC_STRING_TYPE:
        ... 
    }
}

This approach is error-prone. Strings aren’t the best solution to describe action types.

  • it’s easy to make a typo with strings
  • the same characters have to be typed twice
  • There are infinite string combinations, which means we can’t create an exhaustive switch statement.

These issues might be addressed with a TypeScript enum or unions.  However, it’s a load of boilerplate code and too much focus on creating data structures instead of thinking about our data flow.

Using a variant type, we can clearly express what the action type is and what value it carries.

With TypeScript:

interface Login {
    type: "Login",
    value: string	
}
interface Logout {
    type: "Logout"
}
type SignIn = Login | Logout | null;

With ReScript:

type login =
  | Login(option<string>)
  | Logout;

That’s the whole definition of our action. We want to log in and memorize a usernameuser name in the state and log out. We don’t need any wrapper around our actions. Moreover, the ReScript compiler will complain if we try to match an action in the switch clause without covering every case. We have a strongly typed action and a compiler that helps with constructing the reducer. 

With this, we can describe a simple reducer:

let reducer = (state, action) =>
    switch action {
        | Login(Some(name)) => {name} // assuming name is option(string) type
        | Login(None) => {name: “No user”}
        | Logout => {name: “No user”}
    }

Omitting the Login or Logout arm will throw a warning.

Unfortunately, ReScript pattern matching is not exhaustive by default. It will throw a warning if we don’t cover every case. This is possible to change but it will require tweaking configuration. The change will ensure we cover every likely scenario in our reducer, making our application more robust and easier to evolve in the future.

Everything is an expression

One of the very annoying situations in JavaScript & TypeScript is assigning blocks of code result to a variable.

Let’s say we want to assign the result of if/else block to a variable.

In JS or TS we would need to do something like this:

let value;
if (condition === expectation) {
    value = 1
} else {
    value = 2
}

We need to first initialize the variable and then reassign it, which means we’re mutating it. In addition, if we forget the else block, we can quickly end up with unexpected behavior and have to deal with an undefined value.

In contrast this is  ReScript code:

let value = if condition === expectation {
    1
} else {
     2
}

note 1: let in ReScript declares immutable variable

note 2: in ReScript block code returns its last expression. If you return nothing, you still return unit type (), and the return type has too many if/if else/else arms.

In ReScript, everything is an expression, so you can assign if the blocks switch blocks or even just any block. For example:

let value = {
    2 * 2
}

Immutable data

Immutable data is one of the most crucial concepts in functional programming.

In imperative programming, it might sound like a hoax (you have to somehow assign a value from if/else blocks, right?).  It might look limiting from the perspective of a single block of code, but apps are usually built from many chunks of code. 

We usually take our data and pass it to several functions and methods. 

In JavaScript and TypeScript, you can define an object, pass it from one function to another and modify part of it at every step. If something goes wrong, good luck debugging the code, going deep into the executed functions.

Immutable data is more reliable and easier to test. It is easier to ensure that a function always returns the same output with the same input with immutable data.

In addition, in JS and TS you can declare variables with let, const & var. Again, confusing.

In ReScript, you just use let and declare the immutable value by default (except array). You can also define mutable fields in the records, but you will have to explicitly do that.

In most cases, you should create a new value from the old by returning a new one with the changed data.

#6 Modules over objects

This is probably one of the most notable differences between ReScript and the JavaScript/TypeScript world. In JavaScript & TypeScript, it’s a typical pattern to build methods around objects and the inheritance chain. While objects and methods are acceptable, inheritance can easily lead to a long, hard-to-debug chain of classes/objects.

In ReScript, there is no straight equivalent of an object, object methods, and inheritance. Instead, all functionality should be built around a module.

For instance, the Array module provides all functions related to Array data structure. This makes it clear how to structure the application and where the required code is placed.

This approach might be familiar for developers coming from languages like Haskell or Elm, and confusing Java or TypeScript developers.

Hard stuff in ReScript

#1 Code formatting

This is something that could hit you very quickly, especially if you spend time configuring your linters and prettier to get a decent layout of your component’s code. Or if you have got used to languages with strong formatters like Elm or Rust.

In short, the ReScript formatter is not configurable. It enforces code style, including line length. This means your code will be forced to a single line when your pipes or props list in the components is too short.

Not only annoying, but in many cases, it can make the code harder to read.

#2 It’s not  ReasonML

If you are familiar with ReasonML, ReScript might be an unpleasant surprise for you. While all of the tools together might be good (ReScript + BuckleScript), there are a few hits. Some syntax is no longer valid (like fun shorthand for pattern matching unary function), it is deprecated such as triangle pipe |>. ReScript also no longer keeps full sync with Ocaml, becoming a bit of its own language. This might not be a big thing for newcomers, but it has also got some attention from OCaml developers. It might also not be fully compatible with tooling around the ReasonML project (as, for example, the author of the Building Ahrefs codebase with Melange article had.

The worst of this is probably the situation after the “rebranding” to ReScript. Not everyone was excited about it, and as it’s not sure if BuckleScript will still be developed, it seems ReasonML is not entirely dead. There is also a ReScript fork that focuses on OCaml compatibility, and some developers fend back to JS_of_ocaml.

This creates enormous confusion, and because the whole process of rebranding was so long, it may leave developers unsure which project will be the lead one (even if ReScript gets the most attention and effort at the moment).

#3 No Redux alternative

For ReasonML there was the Reductive project. But it’s still not the same experience as Redux. A strong static type system makes things harder for developing extensive applications. With JS there is a lot of comfort in combining multiple reducers. With ReScript and its lack of libraries, it requires writing a lot of abstraction to comfortably work with monorepo and several micro apps that should work both separately and all together.

#4 Not a good option as a proof of concept relying on JavaScript libraries. 

ReScript has improved syntax for JS bindings compared to ReasonML. The primary tool for frontend development is React (React bindings with ReScriptReact), and most libraries have good support for it. With all of this in mind, it still might require time to write bindings for complex library API.

For example, writing bindings for a few types and React components from the Recharts library might be easy, but wrapping bindings for d3.JS might be arduous.

Sum up

Working with ReScript might be a refreshing experience after updating an API with typeless JavaScript or fixing code with any type introduced by a junior developer in your team. It might also be a good step toward more functional code meant for the frontend.

Unfortunately, ReScript is still a young technology, and things like the lack of libraries/bindings or problems with the formatter might be significant drawbacks. On the other hand, after a rough start (rebranding), ReScript is getting better and better. In the last half a year, ReScript documentation has greatly improved, becoming a solid place for newcomers.

So, should you pick ReScript for your next project? I think it really depends on your project needs.

My quick rules of thumb when choosing ReScript


When NOT TO CHOOSE ReScript

It might not be the best choice if you:

  • want a pure functional experience and don’t rely on JavaScript libraries, I recommend looking for Elm.
  • mainly depend on Object-Oriented programming. You should pick TypeScript.
  • have come from the OCaml ecosystem and are strictly coupled with it.
  • need to prototype a JavaScript solution around an existing library with complex API

When to CHOOSE ReScript

It might be a good choice if you:

  • want a functional experience and strongly rely on JavaScript libraries.
  • want to stick to a vast, React ecosystem and widely used tools such as Webpack or CRA and have better type guarantees and more functional code than TypeScript.
  • use React Native
  • have come from the OCaml ecosystem but are loosely coupled with it. ReScript might be a better choice than JS_of_ocaml as it focuses on a typical frontend development that differs from backend flow and tools.

See also

Download e-book:

Scalac Case Study Book

Download now

Authors

Michał Szulczewski

I'm a software developer who started playing with React before ECMAScript 2015 was a thing. Proficient with web dev on both sides with JS, TS, Rescript & Rust. In my free time - ant enthusiast.

Latest Blogposts

28.03.2024 / By  Matylda Kamińska

Scalendar April 2024

scala conferences april 2024

Event-driven Newsletter Another month full of packed events, not only around Scala conferences in April 2024 but also Frontend Development, and Software Architecture—all set to give you a treasure trove of learning and networking opportunities. There’re online and real-world events that you can join in order to meet colleagues and experts from all over the […]

14.03.2024 / By  Dawid Jóźwiak

Implementing cloud VPN solution using AWS, Linux and WireGuard

Implementing cloud VPN solution using AWS, Linux and WireGuard

What is a VPN, and why is it important? A Virtual Private Network, or VPN in short, is a tunnel which handles all the internet data sent and received between Point A (typically an end-user) and Point B (application, server, or another end-user). This is done with security and privacy in mind, because it effectively […]

07.03.2024 / By  Bartosz Puszczyk

Building application with AI: from concept to prototype

Blogpost About Building an application with the power of AI.

Introduction – Artificial Intelligence in Application Development When a few years ago the technological world was taken over by the blockchain trend, I must admit that I didn’t hop on that train. I couldn’t see the real value that this technology could bring to someone designing application interfaces. However, when the general public got to […]

software product development

Need a successful project?

Estimate project