John Jackson
Posted on April 4, 2020
May 2021 update: I wrote this article when Coronate was using ReasonML and ReasonReact. Those projects have since been replaced with ReScript. ReScript provides most of the same features, but the naming and syntax is slightly different.
A little over a year ago, I tried to run a chess tournament but found that there was no free software to manage it. In a world where there is free software for essentially everything, this seemed outrageous. I resolved to solve this problem myself. After all, how hard could it be to throw some code together? As these things usually go, it turned out to be much, much more work than I planned. But, eventually, I finally created Coronate.
The first “official” 1.0 release was last October. Since then, I’ve done a major refactor to bring it in line with best coding practices. Today feels like a good time to review what I learned and what could be done differently.
Part 1: The plan
When I began, I had a short list of goals for the app:
- It needed to automatically pair players.
- It needed to calculate tiebreaks (the rules for these are not simple).
- It needed to follow Swiss tournament procedure.
- It needed to run on a (severely limited) public library computer.
That last goal led me to build it as a web app, so I spun up create-react-app
and started learning React.
Design decisions and limitations
I still consider Coronate to be largely a “proof of concept.” I didn’t code any kind of backend, or any robust way to handle user data. That is all saved in the browser’s storage, which is notoriously fragile. I believe the important part of the app right now is laying the groundwork for the future.
The transition to Reason
During the early months of development, I had no interest in adopting static typing. In fact, one of my favorite things about React was how easily you could just write code and have the app instantly appear on the screen. I didn’t want complicated type systems slowing me down.
But as the project matured, I changed my opinion. The number of modules I was maintaining kept growing: player data, match data, score data, pairing data, and pages and pages of components for rendering all of them. In order for anything useful to happen in the app, data from these modules had to be processed across many functions at once. Keeping these bug-free was growing impossible for me alone.
After playing around with a few JavaScript alternatives, I finally settled on Reason and began a rewrite. That brings us to today.
Part 2: a Reasonable project structure
Note: Coronate is open source on GitHub, and feel free to browse it! I do my best to explain the project in this post, but I believe the code speaks for itself much more clearly.
Playing nicely with Create React App
CRA works almost perfectly with Reason out of the box. There are just a few things you need to do:
-
Follow the official steps to adding Reason. (Note: the latest
bs-platform
version works fine. Install the latest one!) - Add this special Jest and ESLint configuration to your
package.json
:
{
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(reason-[a-z\\-]+|@[a-z\\-]+/bs-[a-z\\-]+|re-[a-z\\-]+|bs-[a-z\\-]+)/)"
]
},
"eslintConfig": {
"extends": [
"react-app"
],
"rules": {
"default-case": "off",
"no-unused-vars": "off",
"react-hooks/exhaustive-deps": "off",
"no-unreachable": "off"
}
}
}
CRA’s default ESLint rules are mostly irrelevant when you’re coding in Reason. The only ones that are definitely worth keeping on are the rules that cover the “rules of hooks.”
Project layout
In short, you should go with the official recommendation: avoid nested folders and have fewer, larger, files.
In JavaScript, every file is a module, and it’s natural to organize files in trees of directories. As a result, you’re constantly import
ing and export
ing files across that tree. Reason’s amazing module system turns that on its head. In addition to the fact that every module is always available with no imports, you can nest as many modules in each file that you want. It’s seriously great to just reference a function without having to figure out which directory and which file you need, and to be able to see your entire project on one screen when you list your files.
In Coronate, I only have two sub-directories of Reason code in src
, and both are to group modules that are all used together. (That’s not counting the directories for tests and mocks.) Once you get used to this module system, it feels painful to try navigating the old, JS-style, system.
Managing state
I loved useContext
in JavaScript. “Prop drilling,” where you pass props down enormous trees of components, was painful.
In Reason, context works but isn’t as safe as using old-fashioned props. I discovered that prop-drilling is actually pleasant with Reason. Because the compiler keeps track of the props so well for you, passing them around doesn’t feel like a chore. But the compiler can’t check context the same way it checks props. By sticking to props, there’s never a worry they’re missing important context.
Minimize JavaScript externals
One slowdown with adopting Reason was adding bindings for all of my npm modules. But I also learned the joy of having as few dependencies as possible. Because Reason has its own standard library built in, a lot of “utility function” packages aren’t necessary. My code is much more readable and maintainable since I only kept the dependencies I absolutely needed.
Part 3: Reason’s best practices and patterns
Interface all the things
I used to hate rei
files. They were exactly why I didn’t want to use a typed language in the first place. Especially in Reason, where you can write code with zero type annotations, why go through the trouble of writing the annotations in a separate file, and then worry about keeping it in sync with the implementation file?
Over time, I realized how useful they can be. Interfaces are a great way to keep yourself organized and look at the “big picture” of your code instead of all of the implementation details. Trying to find the function you need? Just look at the interface file. Looking for better ways to organize your modules? Start by reading the interface files. They also warn you if you broke something. If you refactor an re
file but don’t touch the rei
file, then you can be confident you’ve avoided breaking changes.
For this latest refactor, I made it goal to add an rei
for every re
file, even the ones that just export one React component. Yes, it took some work to write them all, but I’m so much more productive and comfortable with my code now that they’re here.
One module for one thing
Did I mention how great Reason modules are? I say that the more modules we have, the better. Ideally, we should put every type inside its own module with all of the functions for it.
Example
To store configuration data, I had a module like this:
/* Data_Config.rei */
type byeValue = | Full | Half;
/* (With an odd number of players, one is assigned a "bye." That player is
conventionally awarded either 1 or 0.5 points.) */
type pair = (string, string);
module PairCmp: {
type t;
type identity;
};
type t = {
byeValue,
/* avoidPairs stores players who should never be paired together. */
avoidPairs: Belt.Set.t(pair, PairCmp.identity),
lastBackup: Js.Date.t,
};
Seems good so far, but what happens when you start adding more functions?
/* Data_Config.rei */
let byeValueToFloat: byeValue => float;
let byeValueFromFloat: float => byeValue;
let byeValueEncode: byeValue => Js.Json.t;
let byeValueDecode: Js.Json.t => byeValue;
let pairEncode: pair => Js.Json.t;
let pairDecode: Js.Json.t => pair;
/* ...and so on... */
As we pile on the functions, it gets more noisy. Modules to the rescue!
/* Data_Config.rei */
module ByeValue: {
type t = | Full | Half;
let toFloat: t => float;
let fromFloat: float => t;
let decode: Js.Json.t => t;
let encode: t => Js.Json.t;
};
module Pair: {
type t;
type identity;
let make: (string, string) => option(t);
let decode: Js.Json.t => t;
let encode: t => Js.Json.t;
module Set: {
type nonrec t = Belt.Set.t(t, identity);
let empty: t;
};
};
type t = {
avoidPairs: Pair.Set.t,
byeValue: ByeValue.t,
lastBackup: Js.Date.t,
};
let decode: Js.Json.t => t;
let encode: t => Js.Json.t;
Much nicer!
You may object and say reality isn’t always so orderly. What about when you need a function that uses types across multiple modules? If there’s no obvious home for it, then create a new module for it. (This also avoids cyclic dependencies.) As your project matures, the pieces will fall into their right places.
Opaque types
Opaque types are another one of my favorite features in Reason. They allow you to define a concrete type in an implementation, like type t = string;
, but then hide it in the interface, so other modules just see type t;
. Even though it’s a string, you can’t use it in string functions outside of its module.
Example: identifiers
Every piece of data, person, match, etc, needed a string to identify it. But as far as most of the project is concerned, it doesn’t matter if the id is a string or an int or something else. So I set up an Id
module:
/* Data_Id.re */
type t = string;
let random = nanoid();
/* Data_Id.rei */
type t;
let random: unit => t;
See the complete Data_Id.re
and Data_Id.rei
files on GitHub.
This has several benefits. You can’t accidentally do a string operation on an id
. It also helps your tooling. If you look at a function signature and it says it needs a string
, what does that mean? A string could be anything! But when it says it needs Data.Id.t
, then that’s much more helpful.
You can take it to the next level and give every different data type its own id type as well, like Data.Player.Id.t
for example. This makes your code even safer because the id
s for each kind of data can’t be used in the wrong functions. In my case, I didn’t find this necessary, but it could be useful for a larger app.
Give everything an explicit type
One of my all time favorite activities when Reason coding is finding new places to add types. Do you find yourself having to manually constrain certain ranges of numbers? Are you constantly writing if
statements to ensure that people don’t have negative ages, or that people’s names aren’t empty strings? Do you have to deal with several states at once? (E.g.: “loading,” “loaded,” “logged in,” “logged out,”, etc.)
Just give give them their own types and let the compiler find your bugs for you!
Example A: chess match results
I needed a way to store the scores at the end of each match. In chess, the winner receives 1
point, the loser receives 0
points, and, if it’s a draw, both receive 0.5
points. In JavaScript, it made sense to just store the result as one of those numbers, like so: {"whiteScore": 0, "blackScore": 1}
. Then you can easily add them up and calculate the total scores. If you’re given a match and need to know who won, just look at who got the 1
, or if both players got 0.5
, or if both players have 0
to know if it was a draw or not scored yet.
As you can imagine, this adds up to a lot of possible states. In JavaScript, I had to nest if
statements and pray I wasn’t forgetting something. As soon as you try to render a different UI for every possible outcome of a match, it’s a nightmare to keep straight.
Reason simplifies all of that logic to one type:
type t =
| WhiteWon
| BlackWon
| Draw
| NotSet;
Could this code ever confuse anyone? And thanks to Reason’s pattern matching and exhaustive checking, it’s almost impossible for you to render the wrong UI, and it quite literally is impossible to try to access an illegal state.
Of course, the numerical values are still necessary for calculating scores, but adding helper functions for that is simple:
/* Interface: */
let toFloatWhite: t => float;
let toFloatBlack: t => float;
/* Example use: */
toFloatWhite(WhiteWon) == 1.0;
toFloatWhite(BlackWon) == 0.0;
toFloatWhite(Draw) == 0.5;
toFloatWhite(NotSet) == 0.0;
Example B: pairs
Remember the Pair
module from earlier? When I refactored it, I added some extra logic to make it even more robust. Originally, i had type t = (string, string)
, but this has problems:
- It’s possible to pair a player with themselves.
- Comparing pairs is complicated.
After my refactor, it became this:
/* Implementation: */
module Pair = {
type t = (Id.t, Id.t);
let make = (a, b) =>
switch (Id.compare(a, b)) {
| 0 => None
| 1 => Some((a, b))
| _ => Some((b, a))
};
};
With this signature:
/* Interface: */
module Pair: {
type t;
let make: (Id.t, Id.t) => option(t);
};
Note: Pair.t
is opaque now. Outside of its module, there’s no way we can directly access or manipulate the data inside the type. Pair.make
checks to see if the two ids are equal, and it returns None
if they are. If they aren’t, it sorts them before returning the result. When they’re guaranteed to be sorted, it’s much easier to safely compare them.
Not only does this eliminate a whole range of bugs happening across your code, it also makes your code a lot easier to write and understand. When you’re writing the UI, you don’t need to worry about what a Pair.t
is or how it’s structured. The Pair
module takes care of all that for you.
Type-safe router
URLs, are, unfortunately, not smart. They’re just strings that are supposed to represent some kind of location, but they can’t do much besides that. Can you imagine if your 404 errors were all caught at compile time? With Reason, it’s easy!
Inspired by this guide, I implemented a typed router. I won’t reiterate what the guide says, but here’s what my interface for the router looks like:
module TourneyPage: {
type t =
| Players
| Scores
| Crosstable
| Setup
| Status
| Round(int);
};
type t =
| Index
| TournamentList
| Tournament(Data.Id.t, TourneyPage.t)
| PlayerList
| Player(Data.Id.t)
| TimeCalculator
| Options
| NotFound;
let useHashUrl: unit => t;
module HashLink: {
[@react.component]
let make: (~children: React.element, ~to_: t) => React.element;
};
A value like Tournament(id, Round(2))
represents the URL for the third round in the tournament with that id
. Notice how my HashLink
component doesn’t accept a string as its to_
prop, it only accepts one of the defined variants. 404 errors are now almost impossible. If there are any bugs in the URLs, they can only exist inside that router module.
Testing
Once your code is fully immersed in Reason’s type system, testing takes a different character. In many cases, unit testing is no longer necessary. Remember the match result example? In JavaScript, you may need several tests to check that you aren’t creating illegal states or using the results incorrectly. With Reason, there’s no point in testing that, because misusing it is impossible.
Unit tests still have their place, though. For example, Coronate uses a basic Elo-rating algorithm to match players, and I test those functions to guarantee that the math is correct. In another component, I render dates in different formats, and so I test to make sure the formatting is what I expect.
But for the most part, unit testing is less of a focus in Reason. This frees up more time to focus on testing how users actually use the app, not the minutia of implementation details.
Part 4: Reason package shoutouts
The Reason ecosystem is still small, but has some high-quality packages. Here are a few that I use in Coronate:
- Formality: Not just a good Reason package, but a good solution for web forms in general. Extremely well thought out and easy to use.
- bs-json: I use this to encode everything, all of my records, variants, and anything else that needs to be stored.
- bs-css: As far as CSS-in-JS solutions go (or CSS-in-Re solutions), this one is great in how it covers almost all CSS in a type-safe manner.
- re-classnames: It’s simple and it gets the job done.
- Reason Promise: Promises are still a bit clunky in Reason, but this handles them well.
- bs-jest: Reason bindings for Jest. It may install a version of Jest that isn’t compatible with Create React App, so be sure to make sure you have the correct Jest installed too.
Conclusion
Building Coronate was a long journey. In an alternate universe, it may have been built in TypeScript or Elm, but I’m extremely pleased with Reason. The more I code Reason, the more I like it. It just feels so great to see all of your modules logically click into place. Given Reason's relatively small ecosystem, this says a lot about the quality of the language itself.
I’m also keeping my eye on the native side of Reason. Revery looks very promising. (I may use it for a Coronate 2.0 or another future project.) If you’re interested in getting into Reason, now is a great time to learn! The community is working to improve the documentation, and the Discord chat is welcoming.
If you found this helpful and have any questions about Reason or Coronate, just let me know!
Posted on April 4, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.