DEV Community

loading...
Techway

30 minute introduction to ReasonML for React Developers

theodesp profile image Theofanis Despoudis ・8 min read

The next level of React Development is with ReasonML. It allows existing Javascript developers to write OCaml code. The major benefits here are type safe inference (much more pleasant and advanced than Typescript) and really fast compilation times (orders of magnitude faster than Typescript). Not to mention is also very fun to work with.

In this article we'll try to go through as many ReasonML snippets as we can, and explain what the keywords and symbols they contain mean.

Let's get started...

Variable Bindings

let introduces variable bindings. This works the same as const in Javascript:

let greeting = "Hello World"

let bindings are immutable, thus they cannot change after the first assignment:

let greeting = "Hello World"
greeting = "Hello Again"
^^^^^^^^
Error

The let assignment should be done immediately as the compiler needs to infer the type:

let greeting
greeting = "Hello Again"
^^^^^^^^
Error

However, using a ref with a wrapped value passed though allows us to assign a new value later on:

let greeting = ref("")
greeting = "Hello World"

You can create new scopes using braces {} and then assign the result to a binding. Everything you bind inside the scope is not available outside. The last expression evaluated is returned as the result. This is very useful for logical grouping of expressions and for better readability:

let fullName = {
  let first = "Theo";
  let last = "Despouds";
  first ++ " " ++ last
};
"Theo Despoudis"

You can bind new values to existing variables. The value of the last binding is what is referred in subsequent calculations:

let a = 10.;
let a = 11;
let a = a * a; // 121

Type Inference

When we use let without specifying the type, the compiler will infer it:

let a = 10.; // float
let a = 10; // int
let a = "abc"; // string
let a = 'a' // char

If we want to be more explicit about the type we can declare it:

let a: float = 10.;
let a: int = 10;
let a: string = "abc";
let a: char = 'a';

You can assign a different name to a type using type aliases:

type statusCode = int
let notFound: statusCode = 404;

Note that a type name must start with a lower-case letter or an underscore. The following will fail:

type StatusCode = int
     ^^^^^^^^^^
let notFound: StatusCode = 404;

The type system of ReasonML is completely "sound" compared to Typescript which is not. See this article for more information.

Strings

Strings are wrapped in double quotes. Characters are wrapped in single quotes. Strings can span multiple lines:

"aaa";
"bbb;
bbb";
'a';

Strings are unicode encoded but chars are not. They are just ASCII so anything other than ASCII throws an error:

"Ξ±" // Greek letter alpha

'Ξ±';
^^^

Booleans

true and false represent the bool type. All relevant operations that we use in Javascript work the same in ReasonML:

true && false;
true || true;
1 < 2;
2 >= 3;
2 == 2;
3 === 3;

There are no binary or xor operators. The following would not work:

true | true;
false & true;
true ^ true;

Numbers

There are two types of numbers. Integers and floats. Float numbers end with a dot . whereas ints do not.

We use standard operators for integers such as +, -, * and /.

We use different operators for floats such as +., -., *. and /..

1 + 10; // 11
1. +. 10.; // 11.

10. *. 5.; // 50.

We cannot mix operations between types. The following expressions will fail:

1 +. 10 // +. works on floats only
1. + 10; // + works on ints only

Lists and Arrays

Lists and Arrays are collections of similar items. Lists are immutable and the notation is the same as Javascript:

let groceryList = ["eggs", "pasta", "milk"];

You cannot mix types:

let ids = [1,2, "3"];
                ^^^

The type of list is list(<type>) for example list(int) or list(string).

There are not list methods available so you cannot do ids.length. Instead you need to use the List module methods, for example:

let ids: list(int) = [1, 2, 3];
List.length(ids); // 3
let ids = List.append(ids, [4]); // [1, 2, 3, 4]

You can also use the spread(...) operator once to prepend items:

let ids: list(int) = [1, 2, 3];
let ids = [0, ...ids];

Note that appending does not work. You need to use List.concat for anything else:

let ids = [...ids, 4];
           ^^^^^^

To access a list index you need to use List.nth using 0-based indexing:

let ids: list(int) = [1, 2, 3];
let first = List.nth(ids, 0); // 1

Arrays are mutable collections of similar items. We surround them with [| and |] and we can use standard index notation for access:

let ids: array(int) = [|1, 2, 3|];
let first = ids[0]; // 1
ids[0] = 4;
// ids = [|4, 2, 3 |]

Conditional Expressions

if and else are expressions (they return a value) so we can assign them to let bindings. For example:

let ids: array(int) = [|1, 2, 3|];

let safeFirst = if (Array.length(ids) > 0) {
    ids[0]
} else {
    0
}
// safeFirst = 1

You cannot have a naked if expression without an else one:

let ids: array(int) = [|1, 2, 3|];

let safeFirst = if (Array.length(ids) > 0) {
    ids[0]
}^^^^^^^^^^^^^

There is also a ternary operator just like Javascript:

let isLoading = false;
let text = isLoading ? "Loading" : "Submit";

Records

Records in ReasonML are like Objects in Javascript. However they have stronger type guarantees and are immutable:

type user = {
  name: string,
  email: string
};
// Type inference here. This will only work in the same file that the user type is defined.
let theo = {
  name: "Theo",
  email: "theo@example.com"
}

Note that you cannot just define an object without a type:

let theo = {
  name: "Theo",
  ^^^^
  email: "theo@example.com"
}

To use a Record defined in a different file you need to prefix the type. For example if we defined the user model in Models.re:

let theo: Models.user = {
  name: "Theo",
  email: "theo@example.com"
};

Records are immutable:

type user = {
  name: string,
  email: string
};
let theo = {
  name: "Theo",
  email: "theo@example.com"
}

theo.name = "Alex"
^^^^^^^^^^^^^^^^^^

But you can create another Record using the spread operator:

type user = {
  name: string,
  email: string
};
let theo = {
  name: "Theo",
  email: "theo@example.com"
}

let theo = {
  ...theo,
  name: "Alex"
}
// {name: "Alex", email: "theo@example.com"}

Alternatively you can mark a field as mutable and perform updates:

type user = {
  mutable name: string,
  email: string
};
let theo = {
  name: "Theo",
  email: "theo@example.com"
}

theo.name = "Alex"
// {name: "Alex", email: "theo@example.com"}

You can combine different types inside a Record type using type shorthands:

type email = string;
type username = string;

type user = {
  email,
  username
}

Functions

Functions are like es6 lambda expressions. We use parenthesis and an arrow and return a value:

let addOne = (n) => n + 1;
addOne(2); // 3

If the function spans multiple lines we can use a block scope:


let getMessage = (name) => {
  let message = "Hello " ++ name;
  message
}
getMessage("Theo"); // "Hello Theo" 

By default, function arguments are positional and the order matters. We have the option to use named (or labeled) arguments (similar to Python) using the tilde (~) operator.

let getMessage = (~greeting, ~name) => {
  let message = greeting ++ " " ++ name;
  message
}
getMessage(~name="Hello", ~greeting="Theo"); // "Theo Hello"

However once we use one named argument we have to use all of them and not skip anything:

let getMessage = (~greeting, ~name) => {
  let message = greeting ++ " " ++ name;
  message
}
getMessage(~name="Hello", "Theo");
                          ^^^^^^

Any function with more than one argument is automatically carried:

let mul = (a, b) => a * b;
let times2 = mul(2);
let result = times2(3); // 6

Recursive functions are declared via the rec keyword:

let rec fact (n) {
  if (n === 0) {
    1
  } else {
    fact(n-1) * n
  }
}

fact(5); // 120

Nulls, Optionals and Undefined

There is no null or undefined in ReasonML. Instead we have the Option Monad which represent either a value - Some(value) or no value at all - None:

let userName = Some("Alex");
let userName = None;
let userName: option(string) = Some("Alex");

You can use the Belt.Option module to perform common operations for Optionals:

let userName = Some("Theo");
print_string(string_of_bool(Belt.Option.isSome(userName))); // true
Belt.Option.isNone(userName); // false

To check is some object is null or undefined (coming from a network response for example) you can use the following API methods:

Js.Nullable.isNullable();
Js.eqNull();
Js.eqUndefined();

Tuples

Tuples are like lists but they can contain different types of items. For example:

let pair = (1, "Theo Despoudis");
let pair : (int, string) = (1, "Theo Despoudis");

As with lists we cannot use the indexing operator [index]. Instead we need to use destructing to extract the i'th element. This makes tuples useful only when there are small in sized (< 3 elements):

let triplet = (1, "Theo Despoudis", "theo@example.com");
let (_, name, _) = triplet;  // use _ for ignoring the extracted value
name // "Theo Despoudis"

Type Variants

Variants are like Union Types in Typescript. It allows us the describe an OR (|) relationship between two or more types:

type status =
  | NotFound
  | Error
  | Success;

let responseStatus = Error;

You can also pass the types of the arguments in some or all of the Type Names of the variant types:

type animalType =
  | Dog(string)
  | Cat(string)
  | Bird;

let myDog = Dog("Wallace");

You cannot use plain types as variants as they need to be Unique tag names or Types with a Constructor:

type number = int | float;
                  ^^^^^^^^

Destructuring

We have seen destructuring before. When we have a tuple or a Record we can extract some or all of their fields to a binding:

type user = {id: int, name: string, email: string};
let me = {id: 1, name: "Theo", email: "theo@example.com"};
let {name, email} = me;

The above is just a syntactic sugar for:

let name = "Theo";
let email = "theo@example.com"

Pattern Matching

Pattern matching is the golden fleece of Functional Programming languages. Essentially they are switch statements on steroids. For example:

type result =
  | OK(string)
  | NotOK(string)
  | Empty;

let response = OK("Success!");

let log =
  switch (response) {
  | OK(message) => "OK:" ++ message
  | NotOK(message) => "Error: " ++ message
  | Empty => "Nothing happened!"
  };

log // OK:Success

Pipes

Pipes act as syntactic shorthand for function composition. If you have 3 functions f, g, h and you want to call them like f(g(h(a))) you can instead use pipes to call them like:

a
 ->h
 ->g
 ->f

For example:

let userName = Some("Theo");
print_string(string_of_bool(Belt.Option.isSome(userName)));

// or

userName
    -> Belt.Option.isSome
    -> string_of_bool
    -> print_string

Modules

Modules are like namespaces. We use blocks {} to define a module name where we can associate similar types or bindings. This aims to improve the code organisation:

module Arena = {
  type warriorKind =
    | Gladiator(string)
    | Hoplite(string)
    | Archer(string);

  let getName = (warriorKind) =>
    switch (warriorKind) {
    | Gladiator(name) => name
    | Hoplite(name) => name
    | Archer(name) => name
    };
};

Then when we need to reference a module in another file we use the module name:

let warrior: Arena.warriorKind = Arena.Gladiator("Brutus");
print_endline(Arena.getName(warrior)); // "Brutus"

For convenience we can use a shorthand for the module name using the open keyword ideally within it's own block scope:

let event = {
  open Arena;
  let warrior: warriorKind = Gladiator("Brutus");
  print_endline(getName(warrior)); // "Brutus"
};

Promises

Using the Js.Promisehttps://bucklescript.github.io/bucklescript/api/Js.Promise.html) module we can create or interact with promise objects:

let messagePromise =
  Js.Promise.make((~resolve, ~reject) => resolve(. "Hello"))
  |> Js.Promise.then_(value => {
       Js.log(value);
       Js.Promise.resolve("World");
     })
  |> Js.Promise.catch(err => {
       Js.log2("Failure!!", err);
       Js.Promise.resolve("Error");
     });

Note that we prepended a dot . or the uncurry annotation before calling resolve as the compiler will complain. This is because we want the callbacks to be uncurried.

The above will compile to the following Javascript code:

var messagePromise = new Promise((function (resolve, reject) {
            return resolve("Hello");
          })).then((function (value) {
          console.log(value);
          return Promise.resolve("World");
        })).catch((function (err) {
        console.log("Failure!!", err);
        return Promise.resolve("Error");
      }));

That's it

There are more little things to know about ReasonML but in this tutorial we explored the most common ones. Here are some further reference links for learning more about the ReasonML ecosystem:

Discussion (1)

Collapse
stevematdavies profile image
Stephen Matthew Davies

Bucklescript seems to be a dead project, ReasonReact does not work in the latest node, and also you have craftily seemed to avoid explaining how you actually set up this project.

Still patiently awaiting a tutorial that works for 2021

Forem Open with the Forem app