DEV Community

John Jackson
John Jackson

Posted on • Edited on

Building Coronate: type-driven development and scorekeeping

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.

Recently, I was fixing something with my Coronate chess tournament manager app, and I had trouble debugging some faulty scorekeeping code. I opted for a type-driven refactor which ended up erasing the bugs and making the code more robust and easier to manage. This post explains how I did it.

Scorekeeping: the basics

Scores are one of those things that seem intuitive, but get complicated once you try implementing them in an app. In Coronate, players earn one point for winning a match, earn zero points for losing a match, and earn a half-point if the match was a draw. Additionally, there are some tie-break calculations which use negative points.

Different modules in Coronate use scores for different reasons. The most basic is the scoreboard component. It adds up the results of each match for each player so it can rank the overall winners. The same data is also used to automatically pair players in a new round. Other components use the scores to display the match history for each player.

Depending on the language you’re using, and your preferred mode of solving problems, you may have different ideas on how to implement this. When I originally wrote the code in JavaScript, all scores were plain numbers. Match results were stored as 1, 0, or 0.5 for each player, each respectively translating to a win, loss, or draw. The scoreboard just needed to compute the sum of these to determine the overall rankings. This code remained mostly unchanged when I migrated to Reason.

But from a type-driven perspective, we can refine this code. With individual matches, we only need a few specific values. For the overall rankings, we need floating-point numbers. A value like 3.5 makes sense for the scoreboard, but it makes no sense as the result of a match.

The problem I faced

A key part of the scorekeeping involves computing each player’s score against each other player. For example, say we need to know how many times Laura won or lost against Bob. To implement this, I created a Belt.Map for each player called opponentResults. Suppose that Laura had won two matches and lost one match against Bob. The code will take Laura’s opponentResults map, look up the key for Bob, and it will retrieve the number 2, indicating that’s her score against him. If it takes Bob’s opponentResults map and looks up Laura, it will see 1.

This seems simple enough, but it caused me trouble later.

At some point afterwards, I needed to write a component that would display the match history of each player. I looked at the scorekeeping data I was already computing, and I had an idea: I could use the opponentResults map for this purpose as well. That makes sense, right? Just run a reducer on the map to build an array of matches and their results.

I used code that looked something like this (written in Reason):

switch (score) {
| 0.0 => opponentName ++ " - Lost"
| 1.0 => opponentName ++ " - Won"
| _ => opponentName ++ " - Draw" /* a catch-all case so as not to raise an exception */
}
Enter fullscreen mode Exit fullscreen mode

You may have already noticed the problem. The map of opponent results represents the sum of scores against each player, not individual match results! In our imaginary scenario with Laura and Bob, it would list Laura as having played Bob once, and drawn, and Bob as having played Laura once, and won.

(In my defense, the Swiss pairing system tries very hard to never pair two players more than once per tournament, so this situation is rare in reality. It is, however, not impossible.)

A type-driven solution

The fundamental problem is that we’re using scores for two completely different purposes: as the outcomes of individual matches, and as the overall sum score for ranking.

Since Coronate is built with Reason now, I have the luxury of enforcing these differences on the type level.

First, I defined match scores like this:

/* Score.rei */
module Sum: {
  type t;
  let toFloat: t => float;
};

type t =
  | Zero
  | One
  | NegOne
  | Half;
let sum: list(t) => Sum.t;
Enter fullscreen mode Exit fullscreen mode

This probably looks self-explanatory. We only have four possible scores: zero, one, one-half, and negative-one. They’re variants, not numbers. Score.sum takes a list of Score.t and “adds” them to produce a Score.Sum.t. Score.Sum.t is really just a float, but the implementation is hidden by the interface (more on that later). Score.Sum.toFloat is a type cast.

With this, the bug we had encountered previously will be rejected by the compiler. And because our score logic is encapsulated entirely inside one module, there’s little chance of similar bugs happening again.

I also rewrote opponentResults as type list((Id.t, Score.t)). It now actually represents a history, and a reducer function is able to compute the “sum” score against an individual player.

Our code from earlier looks like this now:

switch (score) {
| Zero
| NegOne => opponentName ++ " - Lost"
| One => opponentName ++ " - Won"
| Half => opponentName ++ " - Draw" /* no catch-all case needed! */
}
Enter fullscreen mode Exit fullscreen mode

Continued benefits

This ended up helping me with the other feature I was adding as well. I wanted to add the ability to manually adjust a player’s score. For example, you could add a handicap point for someone. This was tricky because I had to hunt down every place where scores could possibly be used and ensure that the adjustment was being added.

Once the score code was refactored, this task was easy. Instead of Score.sum, I made this function instead:

let calcScore: (list(t), ~adjustment: float) => Sum.t;
Enter fullscreen mode Exit fullscreen mode

Now, any time a score is calculated, an adjustment must be applied as well (usually just 0.0). Because Score.Sum.t is abstract, I’m no longer free to apply whatever math I want to the score anywhere in the code. All of the implementation logic is contained in, and controlled by, the interface.

Conclusion

This is a good example of how you can use Reason-style typing to fully describe how your code is intended to work. When you end up using float or string for everything, you will inevitably wind up accidentally misusing the data. As we saw here, a “number” can mean many different things in different contexts.

Top comments (0)