ReasonML: Safe Identifiers

reasonJanuary 01, 2020Dotby Alex Fedoseev

Pretty much each entity in our apps has special field that uniquely identifies it. Usually, it's called id. 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 identifier of one entity is not confused with identifier of another entity or even some arbitrary int or string. In some cases, like handling of nested lists, it can cause nasty bugs that won't be caught by compiler. But this is fixable.

Consider todo entity of the following type:

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

As a first step to make its identifier safer, let's create TodoId module and move 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 yet since compiler still resolves the type of identifier to int. Basically, we just aliased it in our code but nothing has changed for compiler. To gain extra safety, we must hide 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 TodoId module. And within this annotation, type t doesn't have any special type assigned, it is opaque to the rest of the app. Though inside the module it is still aliased to int, but only internals of 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 todo identifier can never be confused with any other identifier or arbitrary int.

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

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

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

What we did here is hidden implementation of todo identifier in Id submodule and implemented make function which casts int to t using "%identity" external. Now, TodoId.make(1) would produce entity of TodoId.t type.

As a side note, make function doesn't have any runtime footprint and gets erased during compilation. It exists exclusively for 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 (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 make function to Id module which simply does nothing on the runtime level: it accepts argument and returns it back to a caller. The interesting part is in annotation: let make: int => t;. Here we hint to compiler that this function takes int and returns t. Since t is int inside the Id module, it makes perfect sense to compiler. And since 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 doesn't have any runtime footprint, but since we used let binding for make function, this function would be rendered in the output.

Are you looking for a software development partner who can
develop modern, high-performance web apps and sites?
See what we've doneArrow right