Alex Fedoseev
2020, 1 Jan

Safe Identifiers in ReScript

Pretty much every entity in our apps has a special field that uniquely identifies it. Usually, it's called id. The type of such identifier can be int or string (or any other serializable data type). But when we deal with identifiers of these loose types, there are no guarantees that an identifier of one entity is not confused with an identifier of another entity or even some arbitrary int or string. In some cases, like handling nested lists, it can cause nasty bugs that the compiler won't catch. But this is fixable.

Consider a todo entity of the following type:

rescript
type todo = {
  id: int,
  title: string,
}

As a first step to making its identifier safer, let's create a TodoId module and move the type of todo.id to this module:

rescript
module TodoId = {
  type t = int
}

type todo = {
  id: TodoId.t,
  title: string,
}

With this change, we didn't gain any additional safety since the compiler still resolves the type of identifier to int. Basically, we just aliased it in our code, but nothing has changed for the compiler. To gain extra safety, we must hide an underlying type of TodoId.t from the rest of the app and make this type opaque.

rescript
module TodoId: {type t} = {
  type t = int
}

Spot the difference: in this version, we annotated the TodoId module. Within this annotation, type t has no special type assigned — it is opaque to the rest of the app. However, inside the module, it is still aliased to int, but only the internals of the TodoId module are aware of it.

How it affects a program flow:

rescript
// Before
let x = todo.id + 1 // Compiles

// After
let x = todo.id + 1 // Error: This expression has type TodoId.t but an expression was expected of type int

Now we have compile-time guarantees that the todo identifier can never be confused with any other identifier or arbitrary int.

As we will have to deal with the conversion of the raw value of the identifier (from int or JSON or whatever) to the opaque type and back, the TodoId module is going to contain such functions as make, toInt, toString, fromJson, toJson. Let's implement a make function and restructure the TodoId module a bit so we don't have to annotate all its content.

rescript
module TodoId = {
  module Id: {type t} = {
    type t = int
  }

  type t = Id.t
  external make: int => t = "%identity"
}

Here, we hide the implementation of the todo identifier in the Id submodule and implement the make function, which casts int to t using "%identity" external. Now, TodoId.make(1) would produce an entity of TodoId.t type.

As a side note, the make function has no runtime footprint and gets erased during compilation. It exists exclusively for the compiler, and you get compile-time safety with no runtime cost.


If you find using %identity too hacky for your taste, you can get away with another approach that doesn't involve external and has pretty much the same runtime cost, but just a tiny bit of additional code in the output.

rescript
module TodoId = {
  module Id: {
    type t
    let make: int => t
  } = {
    type t = int
    let make = x => x
  }

  type t = Id.t
  let make = Id.make
}

As you can see, we added a make function to the Id module, which simply does nothing on the runtime level: it accepts an argument and returns it back to the caller. The interesting part is in the annotation: let make: int => t. Here we hint to the compiler that this function takes an int and returns t. Since t is int inside the Id module, it makes perfect sense for the compiler. And since the implementation of t is not exposed, make casts int to t on the type level for the rest of the app.

TodoId.make(1) still has no runtime footprint, but since we used let binding for the make function, this function would be rendered in the output.

Making an implementation reusable

Usually, apps have a bunch of different identifiers, and repeating such implementation for each kind of ID is tedious. But we can abstract away this logic into a functor.

A functor is a function in a module space. When you call a functor, it returns a new module. More on functors in the official documentation.

Id.resrescript
module Make = () => {
  module Id: {type t} = {
    type t = int
  }

  type t = Id.t

  external make: int => t = "%identity"
  external toInt: t => int = "%identity"
  let toString = ...
  let toJson = ...
  let fromJson = ...
}

Then you can create *Id modules simply by calling a functor:

rescript
module TodoId = Id.Make()
module TodoListId = Id.Make()