https://www.greyblake.com/blog/from-typescript-to-rescript/
Sergey Potapov
A word from rustacean, rubist and linuxoid
* whoami
* Github
* Twitter
* Programming Books List
From TypeScript To ReScript
2022-01-12
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:
* 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:
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:
* BuckleScript & Reason Rebranding
* ReScript, Reason, ReasonML, and BuckleScript explained
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:
* bs-fetch
* rescript-webapi
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 input json and try to decode it into
a ReScript string.
* Then we pass that string using pipe operator -> to Big.fromString
function, which returns Big.t type.
* We set the result to reward property of
ClaimableRetweetOrderView.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 with setFormErrors
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 (a 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", ())
// This comment would not be possible in normal JSX/TSX
{children}
{React.string("In JSX it would be just a text...")}
}
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, not Array
, 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?