DEV Community

John Jackson
John Jackson

Posted on • Updated on

ReScript's Belt Map and Set: customizing key types

This article was originally written with ReasonML. I updated it in May 2021 to use ReScript.

In my previous post, we reviewed the basics of how Belt’s containers work. Most of the time, especially coming from JavaScript, you’ll just need to use Belt’s String or Int inner-modules (e.g. Belt.Map.String). As you get more comfortable with ReScript, you may find that these types aren’t ideal. For example, what if you need a set of tuples, or a set of records? Or suppose you want to map records to lists. Belt lets you use any type you want for a key, but with a little bit of setup.

Example: a Belt.Set of tuples

In my chess tournament app, Coronate, I needed to track which players should avoid being matched with each other. I created a module called AvoidPairs and made this type:

type t = (string, string)
Enter fullscreen mode Exit fullscreen mode

Each string is a player’s ID. A value such as ("dale_cooper", "audrey_horne") means that the players with those ID strings should not be matched. Belt’s Set data structure was ideal for storing them, because each tuple should be unique. (Possible duplicates would cause bugs elsewhere in the app.) However, the Set module needs a way to tell which ones are unique or not. Consider that tuples (JS array objects) aren’t considered equal based on their contents. To make this more complex, ("dale_cooper", "audrey_horne") should be considered exactly the same as ("audrey_horne", "dale_cooper"). I don’t care about the order as long as they’re paired.

Belt comes with a module called Id which has helper modules for these situations. In this case, we want to use the Belt.Id.MakeComparable functor. If “functor” is a new word for you, just think of it as like a module “function” that accepts modules as arguments and returns a new module. Real World OCaml has an in-depth explanation.

Id.MakeComparable requires a module that has a type t, and a function cmp which accepts two t values and returns an integer (returning 0 means the values are identical).

Here’s the solution I used. Its function cmp compares the four combinations of IDs and returns the result.

module T = Belt.Id.MakeComparable({
  type t = (string, string)
  let cmp = ((a, b), (c, d)) => {
    let w = compare(a, c)
    let x = compare(b, d)
    let y = compare(a, d)
    let z = compare(b, c)
    switch (w, x, y, z) {
    /* Sometimes adding them returns 0 even if they're not equivalent.
     There might be a better way to pattern-match this, but this works. */
    | (1, -1, 1, -1)
    | (1, -1, -1, 1) => 1
    | (-1, 1, 1, -1)
    | (-1, 1, -1, 1) => -1
    | (w, x, y, z) => w + x + y + z
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

May 2021 update: a better way of handling this example is to always ensure the pairs are sorted. You can see how Coronate currently does it here.

The module returned then has to be passed to Belt.Set.make. You may have read that modules exist on a layer outside of the core language, so you can’t do things like store a module in a value. ReScript provides a way around this with “first-class modules.” It’s a feature that enables us to wrap a module into a value that can then be passed to a function, so we can do this:

let empty = Belt.Set.make(~id=module(T))
Enter fullscreen mode Exit fullscreen mode

And now we have an empty set of tuples. This seems like a lot of trouble up front, but it makes your life easier down the road. For example, I can now do this:

/* I want these players to avoid each other: */
let avoidpairs = Belt.Set.add(empty, ("dale_cooper", "audrey_horne"))
/* Later, I need to check if they're in the set: */
Belt.Set.has(avoidpairs, ("audrey_horne", "dale_cooper"))
/* returns true */
Enter fullscreen mode Exit fullscreen mode

Notice that when I called Set.has, the tuple I used wasn’t the same as the original? (And even if the values were in the same order, they wouldn’t be === equivalent anyway.) Yet it still returned true, which is the behavior I want. All of that complex logic is already taken care of, and the rest of my code is easier to use as a result.

Example: A Belt.Map of records

Once you get the hang of this, a lot of concepts become simpler. For example, suppose you have a series of books and you want to map each one to a list of their user reviews.

type book = {
  title: string,
  author: string,
}

type star =
  | One
  | Two
  | Three
  | Four
  | Five

type review = {
  name: string,
  date: float,
  text: string,
  rating: star,
}
Enter fullscreen mode Exit fullscreen mode

One solution is to give each book a unique ID and then then use Map.String. But you can also make a map that takes the book record itself as a key! Simply write a cmp function that compares books.

module BookCompare = Belt.Id.MakeComparable({
  type t = book
  let cmp = (a, b) =>
    switch compare(a.title, b.title) {
    | 0 => compare(a.author, b.author)
    | x => x
    }
})
let reviews = Belt.Map.make(~id=module(BookCompare))

let book = {title: "Incarnadine", author: "Szybist, Mary"}
let review = {
  name: "John",
  rating: Five,
  text: "Great collection! I liked the poem *Invitation*.",
  date: 1568898720165.0,
}
let reviews = Belt.Map.set(reviews, book, list{review})
Enter fullscreen mode Exit fullscreen mode

Ta-da, we used the book as a map key! (Okay, maybe not the physical book itself.) Because we’re comparing the data of books and not an arbitrary ID string, this eliminates bugs such as having duplicate books added. If you do need to distinguish books with the same data, you can just add a new field (such as publisher or edition). In our code, if all of the fields match, then it’s the same item.

Again, this may seem like extra work up front, but it will simplify your code in the long run. At some point, you’re going to have to make these comparisons in your code anyway. This ensures that they’re built into your data structures from the beginning.

Top comments (2)

Collapse
 
idkjs profile image
Alain

Question. How do you get the data into the ui from Belt.Map?

[@react.component]
let make = () => {
  // Belt.Map.toArray converts are Belt.Map type to an array we can use in the ui.
  let reviewsArr = reviews->Belt.Map.toArray;
  let _ = reviewsArr->Belt.Array.map(review => Js.log(review));
  reviews->Js.log2("reviews", _);

  <div>
    {reviewsArr->Belt.Array.map(review => <p> review->React.string </p>)}
  </div>;
};

That doesnt work, obviously.

Error in console is

[3/4] Building src/BookReviewUi-ReactHooksTemplate.cmj

  We've found a bug for you!
  /Users/prisc_000/Downloads/reason-best-practices/src/BookReviewUi.re 50:47-52

  48 │
  49 │   <div>
  50 │     {reviewsArr->Belt.Array.map(review => <p> review->React.string </p
       >)}
  51 │   </div>;
  52 │ };

  This has type:
    (BookCompare.t, list(review))
  But somewhere wanted:
    string

I figured that by using Belt.Map.toArray i'd get back an Array i could work with.

Collapse
 
johnridesabike profile image
John Jackson • Edited

It looks like the error is coming from this code: review->React.string. The review isn’t a string.

A few issues are related:

  1. Map.toArray returns an array of tuples like this: [|(key1, value1), (key2, value2)|]. If you just want an array of values, then use Map.valuesToArray to return [|value1, value2|].
  2. If you’re using my example code, then each value isn’t a review type, it’s a list of reviews (realistically, each book would have more than one review). You’d also have to turn that into an array and map it with a function that returns a React element.
  3. Since a review type isn’t a string, you’d have to pick which field you want to render, e.g.: review.text.

I hope that helps!