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:
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:
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.
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:
// 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.
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.
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.
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:
module TodoId = Id.Make()
module TodoListId = Id.Make()