DEV Community

loading...

How I Switched from TypeScript to ReScript

ronenl profile image Ronen Lahat Originally published at Medium ・15 min read

A glimpse into a more civilized (yet challenging) tool in the JavaScript ecosystem

Article originally published at Medium

This is not evangelism of ReScript or a one-to-one comparison with TypeScript. I love TypeScript. I decided to rewrite a small TypeScript+React+Jest side project into ReScript.

ReScript is not new. In a way it’s as old as JavaScript itself. ReScript is a rebranding of ReasonML (Facebook) and BuckleScript (Bloomberg), which wrap OCaml on both ends. The former is an interface of the OCaml syntax, while the latter makes sure to compile the AST into JavaScript. ReasonML was created by Jordan Walke, the creator of React. ReasonML still exists as a parallel project to ReScript, with a slightly different syntax and mission.

Image for post

ReScript syntax compiling into OCaml Abstract-Syntax-Tree, and BuckleScript compiling into readable, optimized JavaScript

ReScript is not just a rebranding: it’s a ReasonML which freed itself of the yoke of the OCaml ecosystem. By doing so, it forfeited compilation to native code and OCaml library interop, but gained a freer syntax which further resembles JavaScript to embrace its developers, eager for better tools.

First Impression

My first attempt was to just install ReScript on my project, start the watcher, rename an easy file into .res and be guided by the errors. I immediately learned that refactoring into ReScript is not “breadth-first” but “depth-first.” Simply renaming the file extension won’t work, as the compiler stops completely at type errors.

In TypeScript one can gradually assign types and interfaces to dynamic types, while tagging some as unknown or any. Depth-first means that you start with one small function, or one small React component, and write it properly. If all the types are right — and with mathematical precision— your code will compile into JavaScript.

While TypeScript often transpiles into unreadable code, it’s good practice to keep an open tab on the auto-generated js file from ReScript. You’ll be pleasantly surprised by the speed of transpilation, the conciseness and readability of the code, and the performance of such code. If the ReScript code compiled, it means its types are safe and sound, so it can optimize away all the noise.

The only exception I saw to readability and performance of the generated JavaScript was in curried functions. All functions in ReScript are curried by default, and some of them generate code which imports a Currying library. This didn’t happen often, and currying can be disabled.

But what about TypeScript? Inter-operation with JavaScript code is trivial, but importing and exporting types from TypeScript (or Flow) can be more complex, and it creates two sources of truth: one for ReScript types and another for TypeScript.

GenType, described below, auto-generates a typed tsx file from your ReScript code which you can import into other modules. This helped for exporting ReScript types, but it’s not possible to import TypeScript ones. The automation of type conversions eased the problem of the two sources of truth.

Furthermore, the generated ts code uses CommonJs require syntax, which break when using native ECMAScript module support. I also had to tweak my tsc to not transpile the auto-generated tsx into a fourth (!) source file:

  • .res ReScript source code.
  • .bs.js compiled JavaScript, which you can ignore in your source control
  • .gen.tsx auto-generated by GenType, which import the compiled JavaScript code and re-export it with proper types. Also add to your .gitignore.
  • .gen.jsx accidentally transpiled by TypeScript, delete it and reconfigure your tsconfig.json.

I first rewrote my algorithms, since they didn’t have any third-party imports to inter-operate with, and the import syntax was daunting for me at first. Some teams go for a data-first strategy, or a UI-first one (as Facebook did in 2017 for Messenger.com, rewriting 50% of the codebase).

Types

ReScript is part of the statically typed functional programming language family, which means it’s not compiling. Just kidding, it means it uses the Hindley-Milner type algorithm, which deduces types with 100% certainty and can prove it mathematically as long as your variables are immutable (and a few other language design choices). TypeScript on the other hand tries to do it’s best at finding a common type for all your usages.

This might blow your mind as a TypeScript user, but the following ReScript function is fully statically typed:

let add = (a, b) => a + b
Enter fullscreen mode Exit fullscreen mode

ReScript knows with provable certainty that a and b are both int and that the function returns an int. This is because the + operator only works on two int and returns an int . To concatenate two strings you’d use ++ and for two floats use +.. To combine two different types you need to convert either of them. Also, no semicolons.

If you’re like me and like to type your code as you prototype, you can do so as you’d expect:

let add = (a: int, b: int): int => a + b
Enter fullscreen mode Exit fullscreen mode

The generated JavaScript code in both cases is the same (ReScript v8.4.2):

'use strict';
function add(a, b) {  
    return a + b | 0;  
}
exports.add = add;
Enter fullscreen mode Exit fullscreen mode

Notice how I didn’t specify any module exports but the resulting code did. This shows how everything in the module/file is exported by default. The JavaScript function itself is not type safe, so importing it in a JavaScript module and using it there won’t have all the advantages of ReScript.

You can try it for yourself in the official playground.

Generating TypeScript

To interoperate with TypeScript with proper type information you’ll use third-party genType. Add it as a devDependency and annotate the module export you want to generate with @genType (in previous versions you’d surround annotations with square brackets).

// MyModule.res
@genType  
let add = (a,b) => a + b
Enter fullscreen mode Exit fullscreen mode

This will result in the following TypeScript. Notice how the generated TypeScript imports the generated JavaScript MyModule.bs.js file:

// MyModule.gen.tsx
const MyModuleBS = require('./MyModule.bs');
export const add: (_1:number, _2:number) => number = MyModuleBS.add;
Enter fullscreen mode Exit fullscreen mode

GenType generates a one-line re-export of your generated .bs.js file, with proper TypeScript typing. From this example you’ll notice two more things:

  • Every file is a module.
  • Everything is exported.

Here’s an example repo genTyping to TypeScript with React.

For using TypeScript types, see “Importing TypeScript Types” below.

Records

There is only one type which does need a type declaration, which is the record type. A type declaration will look like this and produces no JavaScript code:

type student = {  
  age: int,  
  name: string  
}
Enter fullscreen mode Exit fullscreen mode

Types must begin with a lowercase! If we prepend it with @genType, the generated TypeScript will look like this:

// tslint:disable-next-line:interface-over-type-literal_  
export type student = {  
    readonly age: number;  
    readonly name: string  
};
Enter fullscreen mode Exit fullscreen mode

If you’re wincing at the lower-cased type breaking all your conventions you can rename the type on conversion with @genType.as("Student"). This will add another line of code below the previous one:

export type Student = student;
Enter fullscreen mode Exit fullscreen mode

Also it includes a tslint ignore line, which I hope they switch soon to eslint as the former is deprecated.

These are record types, not ReScript objects (don’t misuse the string type on them). As soon you type something like foo.age ReScript will know that foo is of type student. In case there’s another record with and age field, it will infer it’s the last one declared. In that case you might want to explicitly annotate the type.

In the case you don’t want that much ceremony, you can use the object type and index it with a string: student["age"]; then you don’t need to declare a type.

Furthermore you can use student as a variable name, so student.age is a valid expression, TypeScript would scream at something like this. Variables (that is, bindings) and Types live in a separate namespace, so a student of type student an be written as student: student.

Nominal Typing

Record types have “nominal typing” similar to Java or C#, as opposed to TypeScript’s “structural typing.” This is why interfaces are so important in TypeScript, and are used much more than Types. TypeScript doesn’t really care about “what you are”, it cares about “how you look.”

For instance, if there’s another type, say, teacher with the same fields of a student, you cannot assign a student to somewhere expecting a teacher:

// defined first  
type student = {  
  age: int,  
  name: string  
}

// defined last  
type teacher = {  
    age: int,  
    name: string  
}

// t is a teacher  
let t = {  
    age: 35,  
    name: "Ronen"  
}

let s: student = t // Error!
Enter fullscreen mode Exit fullscreen mode

You’d get a colored error saying:

We've found a bug for you!
//...This has type: teacher
Somewhere wanted: student  
FAILED: cannot make progress due to previous errors.  
>>>> Finish compiling(exit: 1)
Enter fullscreen mode Exit fullscreen mode

Unlike TypeScript’s tsc compiler, bsb won’t begrudgingly continue its transpilation work into working JavaScript. It will stop with a non-zero exit code, and you have to fix the issue in order to make any progress.

Optionals

One of the features I most like in modern TypeScript (or future JavaScript) are the optionals. They make working with nullable types easy and concise:

const something: string = foo?.bar?.baz ?? "default";

something will be the content of baz if it reached that far, or be "default".

There are no null or undefined in ReScript. But we can work with nullable values using the Variant option. But how can we get the elegance of the above TypeScript code? I tried to answer this question but, we can’t, currently. Not enough sugar.

As with other functional languages, we can use a myriad of interesting library functions. Some of Belt utility functions are:

  • Belt.Option.Map will execute a function on the optional value if it exists, or return None.
  • Belt.Option.getWithDefault will return a default if the optional is None.
  • Belt.Array.keepMap will trim away all None values from an array.

But for this case, the best option is with Pattern Matching:

let baz = switch foo {  
   | Some({ bar: Some({ baz: baz })}) => baz  
   | None => None  
}
Enter fullscreen mode Exit fullscreen mode

There isn’t yet a sugared syntax for optionals; the optional operators are very new to TypeScript as well.

The important quality of pattern matching is that the compiler will complain if there’s any case — doesn’t matter how deeply nested — you haven’t addressed. It’s best practice for most cases.

Pipes

Pipes are great. They compile this code:

person  
  ->parseData  
  ->getAge  
  ->validateAge
Enter fullscreen mode Exit fullscreen mode

Into this:

validateAge(getAge(parseData(person)));
Enter fullscreen mode Exit fullscreen mode

Previous versions used a triangle operator |>. The difference is in where to shove the data: as the first parameter, as the arrow does, or as the last parameter, as the deprecated triangle does. More about this.

Notice that in the case of a one-parameter function we don’t write the unit, that is (). This is a common beginner’s mistake. In the case of multiple parameters, the value gets passed as the first one and the other parameters begin with the second one.

This is especially important in a functional language, since we lose some of the elegance of calling methods in objects.

What would be a JavaScript method call such as map:

myArray.map(value => console.log(value));
Enter fullscreen mode Exit fullscreen mode

Has to be written functionally in ReScript as:

Belt.Array.map(myArray, value => Js.log(value))
Enter fullscreen mode Exit fullscreen mode

But can be rewritten as:

myArray -> Belt.Array.map(value => Js.log(value))
Enter fullscreen mode Exit fullscreen mode

As a newcomer I try to find a use for it anywhere I can, which can lead to the bad practice of rewriting code around it to impress my coworkers. To use it on JavaScript libraries you’ll have to write the correct bindings for them. This is one thing I’d like to see in JavaScript. Here are a few stage-1 proposals.

By the way, if you’re not using Fira Code then you’re missing out on a lot of the pipe’s aesthetics.

Promises

This was very frustrating for me. I love using modern async and await syntax in my code, which ReScript didn’t implement yet. I had to go back into thinking about then and resolve, which made simple code look complex.

The following code:

const getName = async (id: number): Promise<string> => {  
    const user = await fetchUser(id);  
    return user.name;  
}
Enter fullscreen mode Exit fullscreen mode

Is de-sugared into:

const getName = async (id: number): Promise<string> =>   
    fetchUser(id).then(user => user.name);
Enter fullscreen mode Exit fullscreen mode

Now consider then to be a function in the Js.Promises module instead of a method, which accepts fetchUser(id) as its last parameter, and you can write it like this:

let getName = (id) =>  
    Js.Promise.then_(  
        user => Js.Promise.resolve(user.name),  
        fetchUser(id))
Enter fullscreen mode Exit fullscreen mode

Typed as Js.Promise.t<string>, and with arrow pipe syntax for readability, the above function can be written as:

let getName = (id): Js.Promise.t<string> =>  
    fetchUser(id) |> Js.Promise.then_(  
        user => Js.Promise.resolve(user.name))
Enter fullscreen mode Exit fullscreen mode

The Promise library still uses the old convention of passing the data as the last argument, so in order to use the newer arrow pipe, an underscore has to be placed in the proper location.

Here are examples for Promises written in the (almost-identical) ReasonML syntax.

The ReScript team promised (no pun intended) to implement a Promise API revamp with their own async and await.

Import JavaScript Modules

If you’re writing only in ReScript you don’t need to bother with imports or exports, and this is done under the hood. Every file is a module and everything in it is exported. If you only want specific things exported you do so with an interface file. To import JavaScript modules however, the syntax can get complicated.

To import dirname from the path module, you’d write:

@bs.module("path") external dirname: string => string = "dirname"

Image for post

the elements of an import from JavaScript files

Then use it accordingly:

let root = dirname("/User/github") // returns "User"
Enter fullscreen mode Exit fullscreen mode

For ReasonReact this became particularly tiresome, as I had to define inline modules for each React Component, and reexport the default export as the “make” function, paying attention to named parameters such as “children.” Here I imported the Container from react-bootstrap and used it in ReasonReact:

module Container = {  
    @bs.module("react-bootstrap/Container")  
    @react.component  
    external make: (~children: React.element) => React.element = "default"  
}
@react.component  
let make = () => <Container> ...
Enter fullscreen mode Exit fullscreen mode

Redex

REDEX: Reason Package Index

For this case I can get the bindings from redex, and add it as a dependency both to my package.json and my bsconfig.json. I can then import it with open ReactBootstrap at the top of my file. This is similar to DefinitelyTyped, where you can find high-quality type definitions for TypeScript.

For this case however I ran into an error, as the package I needed was not updated to the latest version. I had to fork it and manually update it to react-jsx version 3.

Importing TypeScript Types

You can’t import a type from TypeScript and use it in ReScript, you have to re-declare it. However, you can link the type you created to the original TypeScript one for correct inter-operation. Here’s an example with Node.js’ fs module:

@genType.import(("fs", "Dirent"))  
type dirent
Enter fullscreen mode Exit fullscreen mode

Notice that I passed a tuple to import, not an argument list. This will link my type dirent to fs.Dirent, and will generate the following TypeScript:

import {Dirent as $$dirent} from 'fs';_// tslint:disable-next-line:interface-over-type-literal_  
export type dirent = $$dirent;
Enter fullscreen mode Exit fullscreen mode

You can declare the entire type, in case you need to use its properties, or leave it as is.

Because of the syntax overhead of TypeScript-ReScript inter-operation, I recommend doing it as little as possible, using each language in separate areas of your app.

ReasonReact

Image for post

ReasonML (now ReScript) was created by Jordan Walke, the creator of React. Reason+React pushes the React philosophy further by utilizing the language syntax and features for ReactJS’s programming patterns.

ReasonReact provides smooth JS interop and uses built-in language features to integrate into UI framework patterns left unaddressed by ReactJS, such as routing and data management. Using them feels like “just using Reason.

The documentation for ReasonReact still uses the old syntax, so things like:

[@react.component]
Enter fullscreen mode Exit fullscreen mode

Needs to be changed into:

@react.component
Enter fullscreen mode Exit fullscreen mode

If you want to use the old syntax, just change the file extension to .re instead of .res.

ReasonReact is stricter than ReactJS, mainly in its use of types (e.g., strings need to be used with React.string()in JSX. Other than this, the React.useState returns a proper tuple instead of an array, the way it was originally intended. Finally, React Components are rendered through a make function, and prepended with @react.component (I added @genType as well for TypeScript generation):

For the example, I imported this component into a React TypeScript file:

// index.tsx
import { make as Demo } from "./pages/Demo.gen";
// ...<Demo name={"Foo"} />
Enter fullscreen mode Exit fullscreen mode

Which, when rendered, looks like this:

Image for post

In case we don’t want GenType for TypeScript generation, we just import Demo.bs instead.

Testing

In order to write tests in ReScript, and thus test your code directly, you can use bs-jest, which provides ReScript bindings to Jest. If you prefer, you can also use the slightly less mature bs-mocha. You can also test the generated JavaScript or TypeScript files with no extra configuration.

Since ReScript is in the JavaScript ecosystem it makes little sense to create specialized testing tools for ReScript, and the direction seems to be in developing bindings for JavaScript testing tools.

With bs-jest, you have to name you can’t name your file foo.spec.res, only with a valid module name, such as foo_spec.res. Jest will run on the compiled folder, by default inside lib/js. Also, assertions are not executed immediately, but instead returned by the function and run at the end of the suite. It’s a functional way to thing about tests. Consequently, you can only write one assertion per test, which is best practice anyway.

Tooling

ReScript devs did well on prioritizing the plugin for VSCode, which works really well. With the ReScript’s watcher running, you’ll see your Type errors underlined in red, with a descriptive bubble on hover. You also get type hints, formatting, and jumps to definitions. There’s also official support for Vim (both plain Vim and Coc Language Server) and Sublime.

Image for post

Screen capture from rescript-vscode.

The Community

A few times in my coding career I had to work with small communities, and I always loved it. I developed smart-contracts in Solidity, some database queries in the functional language Q, and Roku channels in BrightScript. You end up working with Slack/Discord/Gitter open and code together with the few others going through your similar problems. You don’t even bother checking StackOverflow for answers.

This forces you to read and reread the official documentation and examples, as you don’t want to look like dumb in the chatroom. Also, you’re part of a community maintained by real people, where you can always contribute something interesting, and even shape its development.

Not all communities are alike, of course. I personally found the ReasonML/ReScript community to be welcoming. ReScript has an official forum where you can communicate asynchronously and with a permanent paper record you can search. The core team consists of a handful of developers with public Twitter accounts, and there’s an official blog. I found however that the community hangs around in the ReasonML’s Discord server, in an unofficial ReScript room.

Finally, there’s ReasonTown, “a podcast about the ReasonML language and the community that makes it good,” ReasonConf’s YouTube channel, and Redex, to find bindings for your libraries.

Conclusion

The switch is not easy; a refactor of an existing app is even more difficult given its fatal stop on the first issue. This will certainly hinder its adoption. Popular transpilers, such as TypeScript, SCSS, or CoffeeScript garnered adoption by its ease. Just copy-paste your code — or rename your file — and you’re done.

This is different. ReScript, as with other statically typed functional languages, aims at changing the way code is approached at a fundamental level. I believe we’ll see a greater adoption of functional programming in the future, eventually becoming the default for some industries. This is due to the mathematical approach to types, formal verification of a program’s correctness, and given immutability: less moving pieces and mental mapping.

We’re already at the first stage of adopting a “functional style” in the ecosystem with map, filter, reduce functions in JavaScript. ReScript represent the next — hybrid stage — of a properly functional language from the ML family which compiles to the industry’s standard JavaScript.

Functional programming at its core takes itself seriously. It’s mathematical, formal, and doesn’t comply with hacks. It aspires to deals with truths, not processes. Writing a «functional style» in JavaScript only whets one’s appetite for more, as the language brings one’s good intentions down, not up. ReScript, while frustrating, might be the precision tool for a more civilized future in the ecosystem.

Discussion (2)

pic
Editor guide
Collapse
danielo515 profile image
Daniel Rodríguez Rivero

The history I actually want to hear is: how I convinced my company to use rescript.

Collapse
lessp profile image
Tom Ekander

Really thorough and well written article Ronen, thanks for sharing!