Support Ukraine. DONATE.

From TypeScript To ReScript

About three weeks ago I decided to completely rewrite the frontend of Inhyped.com from TypeScript to ReScript. In this article, I'd like to share my experience and learnings.

You can see my tweets regarding the rewriting, they're marked with hashtag #FromTypescriptToRescript.

The source code of both TypeScript and ReScript versions is available on GitHub.

Why ReScript?

I enjoy Rust's type safety and I've been searching for something similar in the frontend world. In 2021 tried to implement small projects in Elm and Seed. Elm is great and it's the safest language I've ever tried. Seed is a framework in Rust inspired by Elm, meaning I was able to reuse a big portion of code for backend and frontend (data structures and validation rules), which was also amazing! However, both are quite distant from the big existing JS ecosystem.

Eventually, I had decided to use TypeScript when I started working on Inhyped.com as my hobby project. At that moment I had some experience with React and was aware of techniques that helped me to squeeze maximum safety from TypeScript.

A few months ago I got ReasonML/ReScript on my radar thanks to this interview (RU). However, I did not dare to touch the new technology until one Friday evening, when I got extremely upset with TypeScript at my daily job.

I can foresee readers asking why I do not like TypeScript. Don't get me wrong, TS brings a lot of value and prevents many errors if we compare it to the raw JS. But "why TypeScript is not good enough" is a very broad topic, that requires its own article. Here I put it very shortly:

Learning

Next Saturday I spent 5-6 hours reading through the official ReScript Manual and playing with the language in the playground.

For me, it was very plain to learn, mostly just getting familiar with the syntax. I can attribute it to my prior knowledge of Elm, Haskell, and Rust. Anyone else, who has a small prior experience with functional programming would feel the same. For those who never touched it before, it's a great opportunity to stretch your skills and get the taste of functional programming =).

The next day, on Sunday, I started rewriting my project. Of course, there were still things to learn on the way.

Stats

Let's compare both implementations in terms of line of codes.

TypeScript:

✦ ❯ tokei -t=TSX,TypeScript,Rescript
===============================================================================
 Language            Files        Lines         Code     Comments       Blanks
===============================================================================
 TSX                    19         2233         1941           18          274
 TypeScript             15          675          554           22           99
===============================================================================
 Total                  34         2908         2495           40          373
===============================================================================

Rescript:

✦ ❯ tokei -t=TSX,TypeScript,Rescript
===============================================================================
 Language            Files        Lines         Code     Comments       Blanks
===============================================================================
 ReScript               31         3259         2838           43          378
===============================================================================
 Total                  31         3259         2838           43          378
===============================================================================

From those 2838 LOC in ReScript, 430 are bindings and about 250 LOC are decoders. If we disregard those, we get 2158 LOC in ReScipt VS 2495 LOC in TypeScript. Considering that TypeScript has a lot of imports, the code density of ReScript and TypeScript is pretty much the same.

ReScript overview

React

ReScript's ecosystem is well-tuned to be used with React, and honestly, I haven't heard about anyone using it (successfully) with Angular or Vue. In particular, it works well with React hooks, and I am less sure what it would be like to implement class components.

JavaScript interoperability

What makes ReScript stand out from the type-safe alternatives is JavaScript interoperability: reusing existing JavaScript libraries or frameworks is very easy. It's just a matter of finding existing or defining own binding, which is surprisingly easy.

Consider the following example:

module Big = {
  type t

  @module("big.js")
  external fromString: string => t = "Big"
}

Here we define a module Big which has function fromString. The function takes a string argument and passes it to Big() from big.js.

So the following ReScript code

let amount = Big.fromString("12.34")

compiles into this JavaScript:

import { Big } from "big.js";

let amount = Big("12.34");

For more examples you can take a look at my bindings for MUI or near-api-js.

What if you implemented components or functions in ReScript and want to use them in your app which is mostly written in TypeScript? ReScript has @genType macro which generates .tsx files with all the interfaces. You just have to annotate a function or type:

@genType
let add(a: int, b: int): int => a + b

Mostly this works just great, although there sometimes nuances that one needs to learn.

Unfortunately, there is nothing that would convert type definitions from TypeScript to ReScript, because TypeScript's type system is much more complex than ReScript's one.

Reason VS ReScript

About a year ago Reason(ML) was rebranded into ReScript with some changes in the language syntax. It often causes some confusion for newcomers (me including). I recommend learning a little bit of history:

Unfortunately, today it's necessary to understand difference between OCaml, BuckleScript, Reason and ReScript to navigate in the ecosystem comfortably. It's not uncommon when one has to rely on packages written in Reason which has slightly different syntax than ReScript.

HTTP

To send HTTP queries you're likely to use Fetch API. It's possible to implement our own bindings, but fortunately, it's done already for us by others:

I prefer wrapping API calls with functions that receive parameters and return a result with either a successful payload or an error. You can see some examples here.

JSON codecs

Once JSON is received from a remote server you want to turn it into a more specific data type. The official ReScript tutorial gives an example of casting a random JSON into a value of a "concrete" type by leveraging external, which is mostly meant for interoperability with JavaScript. I see it rather as anti-pattern, because:

The proper alternative is to use codecs (encoders and decoders) to parse JSON into domain types. The same concept is used in Elm.

I found 3 libraries for this:

Initially, I wanted to use decco, but it is not flexible enough. In particular, the way it handles variant type is not compatible with the way serde handles enum on backend.

jzon looks unnecessary too verbose to me. So I went with bs-json and it serves me well, except optional decoder, which catches the internal exception and returns None, when it actually must raise. But this can be worked around by implementing our own decoder.

Here is an example of a decoder:

module D = Json.Decode

module ClaimableRetweetOrderView = {
  type t = {
    id: RetweetOrderId.t,
    reward: Big.t,
    tweet: TweetView.t,
  }

  let decode = (json: Js.Json.t): t => {
    {
      id: D.field("id", RetweetOrderId.decode, json),
      reward: D.field("reward", D.string, json)->Big.fromString,
      tweet: D.field("tweet", TweetView.decode, json),
    }
  }
}

There is our domain type ClaimableRetweetOrderView.t and function ClaimableRetweetOrderView.decode which turns an amorphic JSON into t type, performing all necessary checks and raising an error if JSON is not correctly shaped.

Consider the line:

reward: D.field("reward", D.string, json)->Big.fromString

Async/await

ReScript has no async/await support. It was one of my big concerns, but it turned out fine: I had no pain using piped promises. I'd highly recommend using ryyppy/rescript-promise package.

Here is a typical example:

Api.createRetweetOrder(validParams)
->Promise.then(result => {
  switch result {
  | Ok(_) => {
      reloadUser()
      navigateTo("/orders/my")
    }
  | Error(error) => {
      let errors = convertCreateOrderErrorToFormErrors(error)
      setFormErrors(_ => errors)
    }
  }
  Promise.resolve()
})
->ignore

Promise.resolve() is needed just to satisfy the interface of Promise.then because it requires a function that returns a promise. ignore() function is converting anything into unit type () (which means "Nothing"). It's required otherwise the compiler complains about type mismatch: usually, if an expression returns a value it must be used in some way (e.g. be assigned to a variable).

Module System

The way the module system interacts with a file system is a little bit weird. It's not like in other languages I know. Module names are inferred from filenames, but every module is global. This means having two files with the same name in different directories is not allowed. E.g. these two would collide:

/models/User.res
/utils/User.res

Both files define a module with a name User.

This restriction requires some rethinking about how to structure a large code base.

There is also a common workaround for this, which can be seen in rescript-webapi project.

The files above can be restructured as:

/Models/Models__User.res
/Models.res
/Utils/Utils__User.res
/Utils.res

Then within Models.res we create an alias:

module User = Models__User

Now we can access Models.User. Same trick applies to Utils.res.

This is a little bit annoying, but not very crucial.

Data types

Today I hardly imagine myself programming in a language that does not support algebraic data types (don't look at me, I do not miss you Ruby!). TypeScript's union type doing a great job (considering that everything is built on top of JS) , but it never felt natural to me. E.g. I reinvented my own Result type in TypeScript with proper pattern matching.

Now I have my option, result types in place, and proper pattern matching with switch. Often variant types allow us to model domains much more accurately.

A big gain for me as an ability to use newtype (opaque type) technique, something that is not possible with structural typing in TS.

Consider the following code snippet. Both types productId and userId are strings under the hood, but the compiler prevents us from making a mistake by accidentally passing productId to a function that expects userId:

type productId = ProductId(string)
type userId = UserId(string)

let fetchUserById = (id: userId) => {
  // ...
}

let productUserById = ProductId("123")

// ERROR:
// This has type: productId
// Somewhere wanted: userId
fetchUser(productId)

In TypeScript, I implemented RemoteData inspired by the Elm package. For ReScript there is a similar package, called asyncdata.

Error handling

ReScript provides the following mechanisms to express errors:

Whenever is possible you should use result to return errors. In rare cases, you may want raise ReScript exceptions, which are in fact compiled down to JS exceptions with special markers. Hopefully, you'll never need to throw JS exceptions.

ReScript exceptions must be preferred over the regular JavaScript exceptions because they're strictly typed and the compiler is aware of their data shape.

Order of definitions

ReScript does not allow to refer to functions, which are not yet defined. I speculate saying that this is due to the fact, that functions are just variables. Nevertheless, it forces me to structure my code in a file up side down: usually, I try to keep the most important high-level things on top and details below, but with ReScript it's the other way around.

@react.component (à la JSX)

There is @react.component macro that turns a module with function make into React component and allows to use JSX-like syntax.

There is a few points to be made about it:

@react.component
let make = (~children: React.element, ~href: string) => {
  let iconStyle = ReactDOM.Style.make(~verticalAlign="middle", ~marginLeft="4px", ())

  <Link href target="_blank" rel="noopener" underline=#hover>
    // This comment would not be possible in normal JSX/TSX
    {children}
    <LaunchIcon fontSize=#small sx=iconStyle />
    {React.string("In JSX it would be just a text...")}
  </Link>
}

Compiler

Under the hood ReScript is just a tweaked OCaml compiler that compiles OCaml's AST into JavaScript.

The error messages are not that good as in Elm or Rust, but I'd say still better than TypeScript.

However, error messages can be a bit confusing when > in a generic is forgotten or = is typed instead of =>, or | is forgotten in a patter matching | _ => doDefaultAction(). Those are typical errors I did and it took me a few days to pay extra attention to this.

OCaml is super smart by inferencing types. But I am not smart enough for OCaml. I was abusing type inferencing, by living types for most of the functions undefined, until once I got into a trap: there was a type mismatch, but the error the compiler reported was quite far from the original error made by me. It's not a compiler's fault, the fault was purely mine.

So, after this I prefer to explicitly define function interfaces (hello Rust!), to ensure the compiler reasoning about types is in sync with my real intentions.

I got used to Rust, so I was not complaining about TypeScript compilation time. But ReScript's feedback loop is insane, it's just instant: usually in the range of 20ms-60ms when I change a file.

IDE support

I did not have high expectations about this, so I was positively surprised. ReScript has relatively good IDE/editor support (at least for Vim). I have all what I need:

The Community

The ReScript community is very friendly, I got a lot of help, posting random questions on Twitter, StackOverFlow and the ReScript forum. But let's be honest: the community is very small at the moment. As result, there are not many maintained bindings available out there. This is probably the biggest weak point.

Idioms

There is a few idioms I wish I could learn from the official tutorial.

It's common to use very small modules for each single data type coupled with associated functions. The type within a module is typically named t, for example:

module UserId
  type t = UserId(string)

  let fromString = (id: string): t => UserId(id)
  let toString = (UserId(id)): string => id
}

Functions named make usually act like constructors of complex types. For example, the following snippet creates CSS properties:

let style = ReactDOM.Style.make(
  ~verticalAlign="middle",
  ~marginLeft="4px",
  ()
)

Debugger and source maps?

Those questions are often raised by newcomers.

The answer: ReScript has no special debugging support or source maps.

However, there is a good part:

Types are not capitalized

Type names start with a lowercase letter. E.g. array<user>, not Array<User>, it feels a bit weird after long programming in Rust and TypeScript, but again, it's just a question of habits.

Summary

ReScript is not without its drawbacks, the small community and lack of maintained bindings for popular JavaScript libraries is probably the weakest point.

But it stays on the very strong foundation of OCaml and has a very unique value proposition among other frontend technologies I am aware of: Rescript offers the sound type system without giving up on the existing JavaScript/React ecosystem.

Is that not amazing?