From TypeScript To ReScript
Serhii Potapov January 12, 2022 #rescript #typescript #javascript #fpAbout 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:
- TypeScript's type system is over-complex since it tries to be a superset of JS.
- Despite requiring very verbose type annotations, TypeScript does not have a sound type system, meaning it does not guarantee the absence of type-related errors in runtime even if everything compiles fine.
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:
;
;
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:
- It's hard to keep JSON produced by backend and an internal ReScript type representation in sync.
- It's practically impossible, if you use variant types.
- After all, you go for ReScript over TypeScript not to cast types blindly, right? :)
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:
- bs-json - the oldest one, implemented in Reason
- decco - implemented in Reason, provides macro to generate codecs automatically
- jzon- implemented in Rescript
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
- We take a field
reward
from inputjson
and try to decode it into a ReScript string. - Then we pass that string using pipe operator
->
toBig.fromString
function, which returnsBig.t
type. - We set the result to
reward
property ofClaimableRetweetOrderView.t
.
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
- Call an endpoint with valid parameters (
Api.createRetweetOrder(validParams)
) - If the result is
Ok
reload user information and navigate to/orders/my
- If the result is
Error
, do some error transformation and set them as a component state withsetFormErrors
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:
- Result type
- ReScript exceptions
- JavaScript exceptions
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:
- Properties are turned into labeled (named) function arguments and are type-safe
- It's possible to use normal ReScript comments
- Regular text has to be wrapped in
React.string()
which is a little bit annoying - CSS props have to be built with
ReactDOM.Style.make
which is again, slightly annoying.
@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:
- Syntax highlight
- Jump to definition
- Type hints
- Autocomplete
- Simple refactorings (e.g. renaming)
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:
- You are going to have much less runtime errors to investigate.
- You still have
console.log
(Js.log
). - Generated JavaScript code is human-readable and it's not hard to related a generated code to its original sources.
- You can still use the regular JavaScript break points.
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?