Oleksandr Dubenko

TypeScript vs ReasonML – A Comparison

September 01, 2019

A beautiful car

Yes, adding the types works but it this the best way?

Both TypeScript and ReasonML claim to offer statically typed language for web developers that compiles to JavaScript. But there are important differences. TypeScript’s best and the worst feature is that is a superset of the JavaScript. And while having that familiarity with JavaScript is nice, it means that every quirkiness that we love and hate about JavaScript is still there in TypeScript. The types are added on top of the JavaScript and that works, kind of.

What ReasonML offers is a completely different yet familiar language. Different language means it will be hard to learn for JavaScript/TypeScript developers? Well, let’s take a look, shall we?

Declaring a variable

Let’s start from a variable declaration

ReasonML
let a = "Hi";
TypeScript
const a = "Hi"

In ReasonML we declare a variable with let keyword. There’s no const, let is immutable by default.

Both languages inferred type of a in this situation

Functions

TypeScript
let sum = (a: number, b: number) => a + b
ReasonML
let sum = (a,b) => a + b;

Although I didn’t write any types by hand, the arguments of this function are typed. Why don’t we have to write types in ReasonML? Because of the powerful type system that allows for incredible type inference. What it means is that compiler can deduce types without your help. (+) operator in ReasonML works only with integers — the only type that a and b can be, so we don’t have to write them.

But you always can write types, if you want:

ReasonML
let sum = (a: int, b: int) => a + b;

Interfaces, Records

TypeScript
interface Product {
  name: string
  id: number
}

The closest thing to interface in ReasonML is a record

ReasonML
type product = {
  name: string,
  id: int,
};

Records are like TypeScript objects but are immutable, fixed and a bit more rigidly typed.

Let’s use defined structures in some function

ReasonML
let formatName = product => "Name: "++product.name;
TypeScript
const formatName = (product: Product) => "Name: " + product.name

Again, we don’t need to annotate types! In this function, we have an argument product that has a property name of type string. ReasonML compiler can guess the type of that variable based on usage. As the only type that has name property of type string is product compiler will infer it.

Updating records

let updateName = (product, name) => { ...product, name };
const updateName = (product: Product, name: string) => ({ ...product, name })

ReasonML supports spread operator and punning for name and values just like TypeScript

It is also interesting to look at what javascript was generated by ReasonML

function updateName(product, name) {
  return [name, product[1]]
}

Records in ReasonML are represented as arrays. And generated code looks like a human wrote it if humans could remember indices of every property of every type like a compiler does.

Reducer example

This is, in my opinion, is where ReasonML really shines. Let’s compare implementations of the same reducer:

in TypeScript (following this guide https://redux.js.org/recipes/usage-with-typescript)

TypeScript
interface State {
  movies: string[]
}

const defaultState: State = {
  movies: [],
}

export const ADD_MOVIE = "ADD_MOVIE"
export const REMOVE_MOVIE = "REMOVE_MOVIE"
export const RESET = "RESET"

interface AddMovieAction {
  type: typeof ADD_MOVIE
  payload: string
}

interface RemoveMovieAction {
  type: typeof REMOVE_MOVIE
  payload: string
}

interface ResetAction {
  type: typeof RESET
}

type ActionTypes = AddMovieAction | RemoveMovieAction | ResetAction

export function addMovie(movie: string): ActionTypes {
  return {
    type: ADD_MOVIE,
    payload: movie,
  }
}

export function removeMovie(movie: string): ActionTypes {
  return {
    type: REMOVE_MOVIE,
    payload: movie,
  }
}

export function reset(): ActionTypes {
  return {
    type: RESET,
  }
}

const reducer = (state: State, action: Action) => {
  switch (action.type) {
    case ADD_MOVIE:
      return { movies: [movie, ...state.movies] }
    case REMOVE_MOVIE:
      return { movies: state.movie.filter(m => m !== movie) }
    case RESET:
      return defaultState
    default:
      return state
  }
}

Nothing crazy here, we declared state interface, default state, actions, action creators and finally reducer.

and now the same thing in ReasonML

ReasonML
type state = {
  movies: list(string)
};

type action =
  | AddMovie(string)
  | RemoveMovie(string)
  | Reset

let defaultState = { movies: [] }

let reducer = (state) => fun
  | AddMovie(movie) => { movies: [movie, ...state.movies] }
  | RemoveMovie(movie) => { movies: state.movies |> List.filter(m => m !== movie) }
  | Reset => defaultState;

/* No need for additional functions! */
let someAction = AddMovie("The End of Evangelion")

Yep, that is it.

Lets look at what is happening here.

First, there is a state type declaration.

After that is action Variant type

type action =
  | AddMovie(string)
  | RemoveMovie(string)
  | Reset

What is means that any variable with the type action can have one of the following values: Reset, AddMovie with some string value and RemoveMovie with some string value

The Variant is a very powerful feature that allows us to define a type that can have values A or B in a very concise way. Yes, TypeScript has union types but it doesn’t have the same level of integration with language because types were kind of patched to the JavaScript to make TypeScript but Variants are an essential part of the ReasonML language and exists side by side with other language features like pattern matching.

Speaking of pattern matching, let’s look at the reducer.

let reducer = (state) => fun
  | AddMovie(movie) => { movies: [movie, ...state.movies] }
  | RemoveMovie(movie) => { movies: state.movies |> List.filter(m => m !== movie) }
  | Reset => defaultState;

What we see here is a function that accepts the state as a first argument and then matches the second argument to possible values.

We can also write this function like this:

let reducer = (state, action) => {
  switch(action) {
  | AddMovie(movie) => { movies: [movie, ...state.movies] }
  | RemoveMovie(movie) => { movies: state.movies |> List.filter(m => m !== movie) }
  | Reset => defaultState;
  }
}

As matching an argument is a common pattern in ReasonML this kind of functions are written in a shorter format like in the previous code snippet. Parts like this — { movies: [movie, ...state.movies] } look the same as in TypeScript, but what is happening here is not the same! In ReasonML [1,2,3] is not an array but an immutable list. Imagine it as having Immutable.js built-in into the language itself. In this part, we are taking advantage of the fact that the append operation has constant time in ReasonML lists! Coming from JavaScript or TypeScript you might write something like this without much thought and you’re getting performance improvements for free.

Now let’s look at the experience of adding a new action to this reducer. How does that look like in TypeScript? Firstly, you add a new action to type definition then write some boilerplate in form of action creators and yeah don’t forget to actually handle that case in the reducer which is who knows where.

In ReasonML first step is exactly the same but the next steps are anything but. As you hit save after adding a new action to the type definition you are led by the compiler on a gentle stroll across your codebase to handle cases accordingly

You’ll see a warning like this:

Warning 8: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
Sort

Now, this is good developer experience. It will point you to the exact place where you need to handle new action and will also tell you exactly which case is missing.

Null, undefined vs Option

In TypeScript we are burdened with JavaScript legacy of having both null and undefined for representing almost the same thing - an absence of value.

In ReasonML there’s no such thing, there’s only Option type.

ReasonML
type option('a) =
  | Some('a)
  | None;

This is an already familiar variant type. But it also has a type parameter 'a. This is similar to generics in other languages like Option<T>.

Let’s compare more code

TypeScript
interface User {
  phone?: number
}

interface Form {
  user?: User
}

function getPhone(form: Form): number | undefined {
  if (form.user === undefined) {
    return undefined
  }
  if (form.user.phone === undefined) {
    return undefined
  }
  return form.user.phone
}

Accessing nullable properties is one of the simplest cases where you can shoot in your foot. In TypeScript, we can remedy it by enabling strict null checks and then checking manually for undefined values.

ReasonML
open Belt.Option;

type user = {
  phone: option(int)
};

type form = {
  user: option(user)
};

let getPhone = form =>
  form.user->flatMap(u => u.phone);

In ReasonML we can use built-in option type and together with helper functions from Belt standard library we can handle possibly empty values in standardized way

Labeled arguments

I don’t think that anyone will argue that the labeled arguments feature is just plain awesome. Probably everyone has had to look up the order or the meaning of the function’s arguments at some point. Unfortunately, there are no labeled arguments in TypeScript.

TypeScript
function makeShadow(x: number, y: number, spread: number, color: string) {
  return 0
}
const shadow = makeShadow(10, 10, 5, "black") /* meh */

In ReasonML you put a ~ character before argument’s name and it becomes labeled.

ReasonML
let makeShadow = (~x: int, ~y: int, ~spread: int, ~color: string) => {
  0;
}

let shadow = makeShadow(~spread=5, ~x=10, ~y=10, ~color="black")

And yes, you can try and emulate it with an object as an argument in TypeScript, but then you’re allocating an object at every function call :/

TypeScript
function makeShadow(args: {
  x: number
  y: number
  spread: number
  color: number
}) {
  return 0
}

const shadow = makeShadow({ x: 10, y: 10, spread: 5, color: "black" })

Module system

In TypeScript we explicitly export and import anything from file to file.

Hello.ts
export const test = "Hello"
import { test } from "./Hello.ts"

console.log(test)

In ReasonML every file is a module with the name of that file.

Hello.re
let test = "Hello";
Js.log(Hello.test);

You can open modules which makes content available without module name prefix

open Hello;

Js.log(test);

Compiling speed

To compare compile speed let’s compile TodoMVC, as implementation and therefore project size is roughly comparable. What we are measuring is the time it takes to transpile code to JavaScript. No bundling, minifying, etc. [1]

TypeScript + React.js
$ time tsc -p js
tsc -p js  6.18s user 0.24s system 115% cpu 5.572 total

6.18 seconds

ReasonML + ReasonReact
$ bsb -clean-world
$ time bsb -make-world
[18/18] Building src/ReactDOMRe.mlast.d
[9/9] Building src/ReactDOMRe.cmj
[6/6] Building src/Fetch.mlast.d
[3/3] Building src/bs_fetch.cmj
[12/12] Building src/Json_encode.mlast.d
[6/6] Building src/Json.cmj
[7/7] Building src/todomvc/App.mlast.d
[3/3] Building src/todomvc/App-ReasonReactExample.cmj
bsb -make-world  0.96s user 0.73s system 161% cpu 1.049 total

0.96 seconds

Now this also includes compiling ReasonML dependencies, we can measure compiling time of only project files

ReasonML + ReasonReact, only src/
$ bsb -clean
$ time bsb -make-world
ninja: no work to do.
ninja: no work to do.
ninja: no work to do.
[7/7] Building src/todomvc/App.mlast.d
[3/3] Building src/todomvc/App-ReasonReactExample.cmj
bsb -make-world  0.33s user 0.27s system 117% cpu 0.512 total

0.33 seconds

Now that is fast!

BuckleScript considers performance at install time, build time and run time as a serious feature

This is the quote taken from the BuckleScript documentation. BuckleScript is the tool that transpiles ReasonML to JavaScript.

Where TypeScript beats ReasonML (for now)

Not everything about ReasonML is flowers and butterflies. It is a fairly new language… Well, actually no it is based on OCaml which is fairly old but the point is that there are still not a lot of resources online. Googling TypeScript question will more likely to yield an answer than a ReasonML one.

DefinitelyTyped typings amount is just insane and it will take time for ReasonML to match them.

Closing remarks

ReasonML syntax should be really familiar for front-end developers which means that the beginning of the learning curve is not that steep (But still steeper than TypeScript). ReasonML takes the best things from the other languages and tools like Immutable.js, eslint and brings it to the language level. It doesn’t try to be a completely pure programming language, you can always fallback to mutation and imperative programming when you need it. It is crazy fast which is a big part of great developer experience. ReasonML is everything TypeScript tries to be (and a bit more) without all that JavaScript weirdness. You should try it!


[1] Compile time tests performed on a Mid-2015 MacBook Pro with Intel(R) Core(TM) i5-5287U CPU @ 2.90GHz
TypeScript + React.js source code
ReasonML + ReasonReact source code


Blog by Oleksandr Dubenko