Learning ReasonML, part 1

An Interesting Trade-Off Between Reliability And Ease Of Adoption

Throughout my professional life, I've worked on a lot of large applications that were in production for years. There was one thing in common with all of them: a lot of the code was overly complex, and it was difficult to fix bugs and add new features.

While searching for solutions to these problems, I found out that different programming languages' patterns and practices are terrific sources of inspiration. Learning Clojure helped me understand the benefits of immutability. Learning Elm helped me understand the benefits of strong typing. Not only that, a lot of the javascript ecosystem itself is based on other languages' patterns and practices. All of that knowledge made my javascript code better and helped me to be a better developer.

And that brings me to ReasonML. My path to ReasonML was paved this way:

First, I turned to ClojureScript because of the immutability / pragmatic philosophy / simplicity it has as it's core. But, some problems related to reliability and safety were still present, and I thought that strong typing could help.

Second, came Elm. Yes, strong typing does help and working with such a powerful compiler feels great, but I missed the pragmatic nature of Clojure.

Thirdly, I turned to F# / Fable, which feels like a sweet spot. A pragmatic Elm for the front end, and a complete ecosystem with a mature runtime for everything else. But, Fable is a tool seems to be made for people with a F# / .Net background, not a javascript background.

And that's how I got to ReasonML. It's a new syntax for Ocaml, which F# is heavily based on. They are from a family of languages called "ML", which provide a nice developing experience resulting from its type system and compiler features. Also, the language creators are heavily targeting javascript developers, so they are working hard to make the language easily adoptable by current javascript teams.

Note: I'm a curious person and an avid learner. I'm always looking for a new way to look at the problems I face in my day to day life, so I don't mean to imply that I did not find the languages listed above useful! Clojure is great, Elm is great, F# is great, and - why not - javascript is great! :)

I like to start learning a language with a very simple and well-defined spec, usually a function. Trying to implement a UI, or anything involving a lot of side effects is often counterproductive for me. And by writing a simple function, we already can learn a lot about the development workflow, the tooling, and the ecosystem, which are very important and deserve special attention.

The Spec

I'll implement the same algorithm I implemented in Learning Elm, part 1. Recapping:

"3S" -> "Three of Spades" "10H" -> "Ten of Hearts" "QC" -> "Queen of Clubs" "AD" -> "Ace of Diamonds" "3T" -> "-- unknown card --"

The Strategy

When learning Elm, I immediately jumped to a "type-driven" solution. Even though I think type driven development leads to more reliable / elegant code, I believe starting with a more JS-style approach is more compatible with the Reason philosophy.

Let's start by installing ReasonML's CLI bsb and create a new project (as described on the official website):

$ npm install - g bs-platform $ bsb -init my- first -app -theme basic-reason

I'm using VS Code with the ReasonML extension installed (the instructions can be found here). It's a great dev environment, with auto-complete, auto-formatting, and other niceties. A note on auto-formatting: we want to focus on actually solving a problem, so it's a good thing that problems like indentation are not getting in our way. This is something ReasonML has in common with Elm, and projects like Prettier are trying to do with javascript. I strongly recommend auto-formatting all the things!

In a real-world application when performance is a key requirement, we always need to be aware of bundle size and be careful with the amount and complexity of the code generated. So I always keep an eye on the compilation result: if we are writing a demo.re file, Bucklescript will generate a demo.bs.js file in the same folder. Also, for small functions, I recommend copy and pasting to the Try Reason website, and see both the generated JS and equivalent Ocaml code in real time!

A tip: when googling for help, we can usually find some Ocaml code that can help us. Use the Try ReasonML to convert it to ReasonML syntax!

First Approach

Let's start by editing the demo.re and keep npm start running in the terminal:

let parseAndRenderCard = cardStr => cardStr;

If we open the generated js file:

function parseAndRenderCard ( cardStr ) { return cardStr; } exports.parseAndRenderCard = parseAndRenderCard;

It is treating the file as a module! And every variable and function we define in the file will be exported. If we change the value of package-specs.module in the bs-config.json file from commonjs to es6 , we'll have:

function parseAndRenderCard ( cardStr ) { return cardStr; } export { parseAndRenderCard };

This is great to integrate your generated files into a webpack bundled project.

Have you noticed the "No side effect" comment? Bucklescript knows if your code is pure or not! I love this feature, and it helps with the practice of having as much of your code as pure as possible. If we add a log Js.log(parseAndRenderCard("3C")); , we can see that the comment changes:

function parseAndRenderCard ( cardStr ) { return cardStr; } console .log( "3C" ); export { parseAndRenderCard };

"Not a pure module", great. But now that the side effect has been added, we can see an unexpected (at least for me!) behavior: the compiler understood that parseAndRenderCard is the identity function, and generated console.log("3C"); , and not console.log(parseAndRenderCard("3C")); ! Even with such a simple piece of code, we can already see some cool optimizations.

But, you may ask, why does it still generate the function, if it is not being used? It's only because it's being exported. We can make sure the function is not exported if we put it in a block inside the module, like this:

{ let parseAndRenderCard = cardStr => cardStr; Js.log(parseAndRenderCard( "3C" )); }

It will generate:

console .log( "3C" ); export {};

Which is amazing: less code to parse, quicker page load :)

I want to talk about one more cool feature also present in Elm and F# before going to our function: the pipe operator |> . It makes the code much cleaner most of the time, and gets rid of the ugly parentheses. As an example, these two lines of code are equivalent:

Js.log(parseAndRenderCard( "3C" ));

"3C" |> parseAndRenderCard |> Js.log;

Ok! Enough talk, let's implement our function by breaking the problem into smaller ones, and start with a parser for a suit:

let parseAndRenderSuit = suitStr => switch suitStr { | "H" => "Hearts" | "D" => "Diamonds" | "C" => "Clubs" | "S" => "Spades" }; "C" |> parseAndRenderSuit |> Js.log;

switch is the syntax for pattern matching in ReasonML. It works somewhat like a switch statement in JS, but it's more powerful: among other features, the compiler will tell you if you took care of all the possible values for the input. That's one of the key features of ML languages that makes the code more reliable. For the above function, we get the following compiler warning:

You forgot to handle a possible value here, for example: ""

The compiler is right! And we can see that the generated JS code will raise an exception if the input is not one of the four cases. Let's try to fix this by defining a "default" pattern:

let parseAndRenderSuit = suitStr => switch suitStr { | "H" => "Hearts" | "D" => "Diamonds" | "C" => "Clubs" | "S" => "Spades" | _ => "-- unknown suit --" }; "C" |> parseAndRenderSuit |> Js.log;

▶ node src /demo.bs.js Clubs

Great! That's a good start. We have a function that correctly spells the intended suit, and will have a valid output for every possible string input. Let's do the same for the card value:

let parseAndRenderValue = valueStr => switch valueStr { | "2" => "Two" | "3" => "Three" | "4" => "Four" | "5" => "Five" | "6" => "Six" | "7" => "Seven" | "8" => "Eight" | "9" => "Nine" | "10" => "Ten" | "J" => "Jack" | "Q" => "Queen" | "K" => "King" | "A" => "Ace" | _ => "-- unknown value --" }; "7" |> parseAndRenderValue |> Js.log;

Boring but effective. Now we just need to implement the function that separates the input string and calls these two functions to print the full card. The suit is represented by the last character of the string, and the rest represents the value:

let parseAndRenderCard = cardStr => { let length = Js.String.length(cardStr); let suitStr = Js.String.sliceToEnd(~from=length - 1 , cardStr); let valueStr = Js.String.slice(~from= 0 , ~to_=length - 1 , cardStr); let renderedSuit = parseAndRenderSuit(suitStr); let renderedValue = parseAndRenderValue(valueStr); if (renderedSuit !== "-- unknown suit --" && renderedValue !== "-- unknown value --" ) { renderedValue ++ " of " ++ renderedSuit; } else { "-- unknown card --" ; }; }; "AD" |> parseAndRenderCard |> Js.log;

A lot is happening here; let me go step by step:

let length = Js.String. length (cardStr);

Js.String.length is a function that transforms a string into an integer that represents that string length. That's a part of the "functional" way of thinking: every piece of data we need is gathered from data transformations. For instance, if we want an upper case version of a string, instead of calling str.toUpperCase() we would call Js.String.toUpperCase(str) .

Next we have:

let suitStr = Js. String .sliceToEnd(~ from =length - 1 , cardStr); let valueStr = Js. String .slice(~ from = 0 , ~to_=length - 1 , cardStr);

The ~ character is used to denote "labeled parameters". They are simply parameters that need to be named, and it means we can call them in whatever order we prefer. Calling Js.String.sliceToEnd(cardStr, ~from=length - 1); would yield the same result.

Js.String is the name of the module where the functions are located. We could also open the module at the beginning of the file, and use the functions directly:

open Js.String; (...) let length = length(cardStr); let suitStr = sliceToEnd(~from=length - 1 , cardStr); let valueStr = slice(~from= 0 , ~to_=length - 1 , cardStr);

This could be cleaner, but keep in mind that it also "pollutes" the global context, making all functions inside Js.String available.

A tip: make sure to take advantage of VS Code's auto-complete to explore all the modules and functions available to us! I found those three functions without googling, and it was a really nice workflow. It's good to learn things while staying in the same environment, which is another interesting side effect of working with a typed language.

Back to our function, we have:

let renderedSuit = parseAndRenderSuit(suitStr); let renderedValue = parseAndRenderValue(valueStr);

Which is straightforward. And to finish:

if (renderedSuit !== "-- unknown suit --" && renderedValue !== "-- unknown value --" ) { renderedValue ++ " of " ++ renderedSuit; } else { "-- unknown card --" ; };

Notice how we don't need a return keyword: the last line of a function expresses the return value. So we are simply checking if the values generated were the ones that represent unknown values. If that's not the case for both the value and the suit, we build the final string in renderedValue ++ " of " ++ renderedSuit ( ++ is the operator for string concatenation).

That's it, a working implementation of the spec! This function is already more reliable than the average javascript function, and we can have full confidence all string inputs are going to generate a valid output, no exceptions will be raised, and all the invalid cases are covered.

But this implementation can improve.

A Second Approach: Option

My main issue with the previous implementation is the two "-- unknown somethings --" possible values for the value and suit parsers. There's an interesting way of dealing with cases like this in ReasonML: the Option type. It represents values that may or may not be present, and that's exactly what the output of the function should be: we may or may not have a valid suit:

let parseAndRenderSuit = suitStr => switch suitStr { | "H" => Some( "Hearts" ) | "D" => Some( "Diamonds" ) | "C" => Some( "Clubs" ) | "S" => Some( "Spades" ) | _ => None };

VS Code shows us that now the function is of type (string) => option(string) , which is much more descriptive of what it does. It makes the code more expressive.

Option is a safe way of dealing with data that may not be present, instead of having the null and undefined checks in javascript. This is how we can work with an option in ReasonML:

let printSuitExample = suitOption => switch suitOption { | Some(suit) => Js.log( "Suit: " ++ suit ++ "." ) | None => Js.log( "Input was not a valid suit." ) };

Alright, so let's also rewrite parseAndRenderValue using option:

let parseAndRenderValue = valueStr => switch valueStr { | "2" => Some( "Two" ) | "3" => Some( "Three" ) | "4" => Some( "Four" ) | "5" => Some( "Five" ) | "6" => Some( "Six" ) | "7" => Some( "Seven" ) | "8" => Some( "Eight" ) | "9" => Some( "Nine" ) | "10" => Some( "Ten" ) | "J" => Some( "Jack" ) | "Q" => Some( "Queen" ) | "K" => Some( "King" ) | "A" => Some( "Ace" ) | _ => None };

Now that we changed those two functions to be more expressive, we can check that the compiler is not generating a new JS file. Our parseAndRenderCard is no longer valid, so we need to change it. In my previous experience with strongly typed languages, this is the strongest point: refactoring feels safe! The compiler tells you exactly what breaks, and you just need to go there and fix it. In our case, let's exchange the last If to a pattern match:

let parseAndRenderCard = cardStr => { let length = length(cardStr); let suitStr = sliceToEnd(~from=length - 1 , cardStr); let valueStr = slice(~from= 0 , ~to_=length - 1 , cardStr); let renderedSuit = parseAndRenderSuit(suitStr); let renderedValue = parseAndRenderValue(valueStr); switch (renderedValue, renderedSuit) { | (Some(value), Some(suit)) => value ++ " of " ++ suit | _ => "-- unknown card --" }; };

Yes, we can pattern match on more than one value, and yes, it's awesome :)

The complete file can be found here.

I like this implementation a lot more than the previous one, mainly for how well it expresses intention by using the option types. Now let's go further, and completely decouple the parsing and the rendering phases.

A Third Approach: Decoupling Parsing And Rendering

To achieve decoupling, we need to parse the string to a card representation, and then build a function that transforms this representation into a string. First, why would we do that? The main reason would be if we want to render in different ways, let's say we want to render to the DOM instead of logging to the console, or maybe our domain is getting so complex that the "parseAndRender" function is too large and complex. In our case, let's do it for fun and learning.

In javascript world, all data is represented mainly by objects and arrays. A card representation would be something like:

const card = { value: CardValueConstants.QUEEN, suit: CardSuitConstants.HEARTS };

The suit is enum-like, and we usually represent it in javascript by having an object with strings or symbols as values:

const CardSuitConstants = { HEARTS: "HEARTS" , DIAMONDS: "DIAMONDS" , CLUBS: "CLUBS" , SPADES: "SPADES" };

Now if we want to check against a value, or check if a given string is a valid suit, we import and use this object.

In ReasonML, we would represent the suits this way, which works like an enum of symbols:

type suit = Hearts | Diamonds | Clubs | Spades;

These are called Discriminated Unions. The best part of working with them is that when we pattern match on a variable of this type, the compiler will make sure we handle all the cases.

For the card value, we need a little more information for the numbered card cases. Back in JS world, we could add a number property:

const card = { value: CardValueConstants.NUMBER, number: 7 , suit: CardSuitConstants.CLUBS };

The weakness of a representation like that is that "impossible" cards are easy to be represented. So we need to make sure we are always testing not only for value being equal CardValueConstants.NUMBER , but also to see if there's a number property present and that it's a valid number.

In ReasonML, we would represent the values as:

type value = | Ace | King | Queen | Jack | Num(int);

The most interesting characteristic of this way of representing the value is that there's simply no way of having a card of value Queen that also has a number, or to have a numbered card without a defined number. This is huge, and, combined with pattern matching, this will make our code very reliable without the need to test the existence of properties everywhere.

Note: there's still impossible states that can be represented this way, for instance Num(333) . There are only numbered cards from 2 to 10. We could list all cases explicitly, and our function would be even more reliable, but let's continue with this representation since it'll lead to more interesting code and more opportunities to learn.

Our final card representation can now be:

type card = | Card(value, suit);

Which is a single case discriminated union. There's no reason to use one, we could use a simple tuple of type (value, suit) , but I find this expresses the intent better.

Renderer

Now let's write the renderer. It will be simple, since it's only a matter of matching all the available cases:

let suitToString = suit => switch suit { | Hearts => "Hearts" | Diamonds => "Diamonds" | Clubs => "Clubs" | Spades => "Spades" }; let numToString = num => switch num { | 2 => "Two" | 3 => "Three" | 4 => "Four" | 5 => "Five" | 6 => "Six" | 7 => "Seven" | 8 => "Eight" | 9 => "Nine" | 10 => "Ten" | _ => failwith( "this is an exception from numToString" ) }; let valueToString = value => switch value { | Ace => "Ace" | King => "King" | Queen => "Queen" | Jack => "Jack" | Num(n) => numToString(n) }; let renderCard = card => switch card { | Card(value, suit) => valueToString(value) ++ " of " ++ suitToString(suit) }; Card(Num( 8 ), Hearts) |> renderCard |> Js.log;

The only different code here is the failwith case in numToString . It's there because we need to handle all the integers when pattern matching, but we'll make sure never to have an invalid number. Again, we could deal with it by being explicit with all the cases, but sometimes it's not possible - imagine if we had an imaginary deck with numbered cards from two to a thousand. The best way to handle a situation like this would be to make sure the functions that output cards never return invalid cards, and it's a good place for unit and generative tests. Once more: strongly typed languages are much more reliable than dynamic languages, but it does not mean they are 100% reliable - we still have to be careful. But it's much, much easier to write correct code :)

Note: there are attempts at expressing some of those constraints in the types themselves, like saying a variable is a number between 2 and 10. Some languages already have this ability, such as Ada>). Other more recent languages are trying to deal with this more generically through dependent types, such as Idris>) and F*>).

Parser

The parser will be a function that takes a string and returns a card option. Let's start with the simple suit parser:

let parseSuit = suitStr => switch suitStr { | "H" => Some(Hearts) | "D" => Some(Diamonds) | "C" => Some(Clubs) | "S" => Some(Spades) | _ => None };

The function type is inferred as (string) => option(suit) which is exactly our intention.

For the value, I'll separate the number parsing to the other card values. Supposing we have a parseNumValue function implemented, we could use it like this:

let parseValue = valueStr => switch valueStr { | "A" => Some(Ace) | "K" => Some(King) | "Q" => Some(Queen) | "J" => Some(Jack) | n => parseNumValue(n) };

We are defining outputs for the "A", "K", "Q" and "J" inputs, and calling the parseNumValue function with the value if it did not match any of the previous. The compiler will not let us continue if our parseNumValue does not return a suit option! Let's implement it, and learn some new ReasonML concepts:

let parseNumValue = numStr => { let parsed = try (Some(int_of_string(numStr))) { | Failure(_) => None }; switch parsed { | Some(n) when n >= 2 && n <= 10 => Some(Num(n)) | _ => None }; };

Starting with the parsed variable: we want to transform a string that may be a number into an integer variable. There's a standard function for that, int_of_string . But it raises an exception if the string input is not a valid integer. That's not the behavior we want here. We want a function that returns a value option, in this case, a Num(int) option, so we need a try expression.

It works similarly to javascript's try / catch, with the difference being that it returns the value of the provided expression if it does not raise, and pattern match on the exceptions if it does raise. Looking at int_of_string documentation we can see that the exception is of type Failure(string) so we catch it and return None in that case.

Note: be really careful when working with exceptions. They are not defined in the function types, so the compiler can't help you make sure you're handling all cases. Also, you need to look at documentation to understand what to match for. As a rule of thumb, never use or rely on exceptions. Only use them if it's needed for integrating with existing code (our case here), or if somehow it makes the code simpler.

Another note: The Js.Option module provides a some function that returns a Some option variable. It's useful if we want to use the pipe operator to get rid of parenthesis:

open Js.Option; (...) let parsed = try (numStr |> int_of_string |> some) { | Failure(_) => None };

It's a matter of taste, and I think the pipe operator can make the code more elegant. I'm excited that it's being considered for javascript too!

Continuing with our function, there's something different in the pattern match:

switch parsed { | Some(n) when n >= 2 && n <= 10 => Some(Num(n)) | _ => None };

We can put constraints on the patterns we want to match with the when keyword. And since this is the only function that generates a Num(int) , we are guaranteeing that there no invalid cards! Of course, as I said before, in real life we should write some tests, and I'll cover them later in this series.

And, to finish the parser:

let parseCard = cardStr => { let length = Js.String.length(cardStr); let suitStr = Js.String.sliceToEnd(~from=length - 1 , cardStr); let valueStr = Js.String.slice(~from= 0 , ~to_=length - 1 , cardStr); switch (parseValue(valueStr), parseSuit(suitStr)) { | (Some(value), Some(suit)) => Card(value, suit) |> some | _ => None };

Gluing The Pieces Together

It would be amazing if we could simply use our functions like this:

"8H" |> parseCard |> renderCard |> Js.log;

But we get the (very good btw) compiler error message:

We 102 │ 103 │ /* example */ 104 │ "8H" |> parseCard |> renderCard |> Js.log; This has type: (card) => string But somewhere wanted: ( option (card)) => The incompatible parts: card vs option (card)

Note: Elm is known for having amazing error messages - and it really does have them. It's nice that they were vocal about it, and now this practice is "leaking" to other languages! Congratulations to the ReasonML / Bucklescript team for borrowing the right features from different projects.

Alright, so we can't pipe our functions because parseCard returns an option(card), and renderCard 's input is a card. Let's use this opportunity to build a couple of helper functions!

First, wouldn't it be useful to have a function that receives an option of something and a function of something, and applies the function to the value if it's a Some, and does not do anything if it's a None? This function is called map , and it will help our pipeline:

let optionMap = fn => opt => switch opt { | Some(x) => fn(x) |> some | None => None };

This is a higher order function, so now we can use:

"8H" |> parseCard |> optionMap(renderCard) |> Js.log;

And renderCard will only be called if parseCard returns a Some. But, after saving the file, we can see that the declaration of the function is changed by the code formatter to let optionMap = (fn, opt) => (...) ! Does this mean that ReasonML doesn't like higher order functions? No, it's the opposite: in ReasonML and most other ML languages, all the functions are curried by default. That means that, differently from usual JS functions, if you call a function with fewer input parameters than the function was expecting, you will have another function as a result that will expect the other parameters. A simple, classic example:

let sum = (x, y) => x + y; let sum5 = sum( 5 ); let eight = sum5( 3 );

Ok, back to our code, now our file compiles, but the logged output is an array of string instead of a string. That's because optionMap(renderCard) returns a string option, not a string! If we feed the pipeline with an invalid card, say "1X", We'll see that the logged output will be "0", which is how Bucklescript translates None to javascript.

So let's implement a function to transform a string option into a string. We can do it by returning the string itself if it's inside a Some, or returning a default value if it's a None:

let optionWithDefault = (defaultValue, opt) => switch opt { | Some(x) => x | None => defaultValue }; "2D" |> parseCard |> optionMap(renderCard) |> optionWithDefault( "-- unknown card --" ) |> Js.log;

And now we have everything we need! Working functions, confidence that they'll do what we want them to do, and elegant implementations.

Note: have you noticed the inferred type for optionWithDefault ? It's ('a, option('a)) => 'a , which does not mention strings. 'a is a generic type, so that means this function will work with options of anything. The only thing we have to be careful with is that the default value passed must be of the same type inside the option :) That means that optionWithDefault(0, Some(5)) or optionWithDefault(Card(Ace, Diamonds), None) are both valid! (and the same thing happens to optionMap , check it out).

We can also divide our functions into modules. In ReasonML, every file is a module, but we can also define modules inside a file, so let's do it to better organize our functions:

type suit = (...) type value = (...) type card = (...) module Parser = { let parseNumValue = (...) let parseValue = (...) let parseSuit = (...) let parseCard = (...) }; module RenderToString = { let numToString = (...) let valueToString = (...) let suitToString = (...) let renderCard = (...) let defaultErrorCard = "-- unknown card --" ; }; module Option = { let map = (...) let withDefault = (...) }; "JH" |> Parser.parseCard |> Option.map(RenderToString.renderCard) |> Option.withDefault(RenderToString.defaultErrorCard) |> Js.log;

Good, and we can extract the modules as single files if they get too large. I like the fact that we can have exactly the same abstraction as code and as a file. That makes the extraction of files mostly just an organizational issue.

Spec Change!

As I've done in the Learning Elm series, let's change the specs. Let's suppose we also want to parse "J" into a joker card and render it as "Joker". How would we represent a joker card? It does not have a value or a suit. So probably the best place to represent it is by changing the card type itself:

type card = | OrdinaryCard(value, suit) | Joker;

We can already see the compiler complaining, and that's another benefit of having this card representation defined. The compiler uses this information and helps us by pointing to the places that need to be changed, so our application works as intended.

The function that we need to change is, according to the compiler, renderCard . So let's change it to deal with the new card type:

let renderCard = card => switch card { | OrdinaryCard(value, suit) => valueToString(value) ++ " of " ++ suitToString(suit) | Joker => "Joker" };

Simple and easy! The compiler also says parseCard needs some work, so use the opportunity to think about how to parse "J" into the Joker representation. The "J" string is structured differently since it does not have a defined suit. So it may be better not to change parseValue or parseSuit . Let's try to pattern match "J" first, and then call our parse function to parse the string if we didn't already identify it as a Joker.

Let's first rename parseCard to parseOrdinaryCard , and then make sure we handle "J" in a new parseCard function:

let parseOrdinaryCard = cardStr => { let length = Js.String.length(cardStr); let suitStr = Js.String.sliceToEnd(~from=length - 1 , cardStr); let valueStr = Js.String.slice(~from= 0 , ~to_=length - 1 , cardStr); switch (parseValue(valueStr), parseSuit(suitStr)) { | (Some(value), Some(suit)) => OrdinaryCard(value, suit) |> some | _ => None }; }; let parseCard = cardStr => switch cardStr { | "J" => Some(Joker) | str => parseOrdinaryCard(str) }; "J" |> Parser.parseCard |> Option.map(RenderToString.renderCard) |> Option.withDefault(RenderToString.defaultErrorCard) |> Js.log;

And we're done. Amazingly, ReasonML's changes to the syntax did not affect the super refactoring powers of Ocaml :)

The final code for the function can be found here.

Conclusion

First of all, all the benefits present in Fable are present in ReasonML - and that's great. Just like Fable, it's a nice pragmatic Elm, and out of all the languages, it was the easiest to just start a project - I did not have to install any different tools, and the main packages are in npm. So that's a win for ReasonML :)

In the next part of this series, I'll start writing an actual web app, and I'll use the React integration library called ReasonReact. I think this is where all the mentioned languages will differ the most. In the javascript world, I'm finding myself using a "pure React" model more and more, and it seems ReasonReact will work well with it. Let's find it out together!

A last comment: ReasonML's Discord channel is a great place, and the language maintainers are very active and helpful. Thank you for helping and answering questions so quickly!

December 30, 2017.