Type equivalence

If I want to differentiate one int type from another at compile time in order to achieve Name equivalence as opposed to Structural equivalence, then it appears that I need to define a module with an abstract type t in the signature. For example:

module type SomeId = {
  type t
  let make: int => t
  let getValue: t => int
}

module SomeId: SomeId = {
  type t = int
  let make = x => x
  let getValue = t => t
}

Now I can reference SomeId.t where I previously would have indicated int:

type something = {
  id: SomeId.t,
  description: string,
}

instead of:

type something = {
  id: int,
  description: string,
}

However, now I can not use the convenient Map.Int.t for storing state in a map and instead need to generate a custom Map.t using Id.MakeComparable and something called Pervasives.compare, for which I did not find any documentation.

Anyway, this is a long way of asking if all of this extra work is the price I have to pay to get Name equivalence? Or, is there an easier way in ReScript to accomplish this?

There are two ways to solve this problem.

One is to make SomeId.t a private int, which allows nominal typing but still allows the compiler to know the underlying type, so you can use Belt.Map.Int by casting SomeId.t to int:

module type SomeId = {
  type t = private int
  let make: int => t
  let getValue: t => int
}

module SomeId: SomeId = {
  type t = int
  let make = x => x
  let getValue = t => t
}

let someId = SomeId.make(1)
let map = Belt.Map.Int.empty
let map = Belt.Map.Int.set(map, (someId :> int), "hello")
let map = Belt.Map.Int.set(map, 2, "hello") // this is unfortunately accepted

The downside of this solution is that you can still use regular int in this map.

Another (maybe cleaner) way to achieve the same effect is by defining a new Map signature inside the SomeId module that would conform to Belt.Map.Int implementation:

module type SomeId = {
  type t
  let make: int => t
  let getValue: t => int
  module Map: {
    type key = t
    type t<'value>
    let empty: t<'value>
    let set: (t<'value>, key, 'value) => t<'value>
  }
}

module SomeId: SomeId = {
  type t = int
  let make = x => x
  let getValue = t => t
  module Map = Belt.Map.Int
}

let someId = SomeId.make(1)
let map = SomeId.Map.empty
let map = SomeId.Map.set(map, someId, "hello")
let map = SomeId.Map.set(map, 2, "hello") // this will error out

With this solution, only SomeId.t will be accepted inside this map.

4 Likes

I am not yet familiar with the t<'value> or even 'value nomenclature, but that is an interesting solution. Is there a way for the Map to have access to the int type without the Map module being contained within the SomeId module? some sort of “friend” relationship?

My solution for that is a little unwieldy with all of the code repetition in the various module types, but this is what I came up to accomplish the following:

  • hide the actual type of SomeId.t
  • hide the actual type and implementation of Collection.t
  • include both modules within a parent module

Some kind of structure like this:

module type Something = {

  module type SomeId = {
    type t
    let make: int => t
    let getValue: t => int
  }
  module SomeId: SomeId

  type t
  let make: (SomeId.t, string) => t

  module type Collection = {
    type something = t
    type t
    let make: () => t
    let add: (t, something) => t
    let get: (t, SomeId.t) => option<something>
  }
  module Collection: Collection
}

module Something: Something = {

  module type SomeId = {
    type t
    let make: int => t
    let getValue: t => int
  }

  module SomeId: SomeId = {
    type t = int
    let make = x => x
    let getValue = t => t
  }

  type t = {
    id: SomeId.t,
    name: string
  }
  let make = (id, name) => {id, name}

  module type Collection = {
    type something = t
    type t
    let make: () => t
    let add: (t, something) => t
    let get: (t, SomeId.t) => option<something>
  }

  module Collection: Collection = {
    type something = t
    type t = Belt.Map.Int.t<something>
    let make = () => Belt.Map.Int.empty
    let add = (t, something) => {
      t->Belt.Map.Int.set(SomeId.getValue(something.id), something)
    }
    let get = (t, id) => {
      t->Belt.Map.Int.get(SomeId.getValue(id))
    }
  }
}

let someId = Something.SomeId.make(1)
let something = Something.make(someId, "The Thing!")
let couldBeAMap = Something.Collection.make()
let couldBeAMap = Something.Collection.add(couldBeAMap, something)
let maybeSomething = Something.Collection.get(couldBeAMap, someId)

EDIT: renamed Collection function set to add and added get

1 Like

t<'a> is the syntax to define a polymorphic type t where 'a is the the type parameter.

You’re mixing the type of the value and the type of the key in your collection, so it’s a bit hard to follow to be honest.

Maybe it’d be easier if you explained more concretely what you’re trying to achieve.

Well if you don’t want to have the Map inside the SomeId module and want to convey the relationship with the type differently, then use a regular Map from Belt like this:

module type SomeId = {
  type t
  let make: int => t
  let getValue: t => int
  let cmp: (t, t) => int
}

module SomeId: SomeId = {
  type t = int
  let make = x => x
  let getValue = t => t
  let cmp = Pervasives.compare
}

module SomeIdCmp = Belt.Id.MakeComparable(SomeId)

let empty = Belt.Map.make(~id=module(SomeIdCmp))

let someId = SomeId.make(1)
let map = Belt.Map.set(empty, someId, "hello")
let map = Belt.Map.set(map, 2, "hello") // this will error out
1 Like

I may have gone down a rabbit hole, ha ha, but I came up with a functor that facilitates Name equivalence when working with multiple entities that each have their own int id. It is debatable how much value this actually creates given the amount of work that went into it, but I can see myself using it when loading database records of multiple entity types.

A simple example of using the functor within Entity.res:

module CarData = {
  type t = {
    id: int,
    make: string,
    year: int
  }
  let id = t => t.id
}
module Car = Entity.MakeEntity(CarData)

let cars = Car.Map.empty
let car = Car.make({id: 1, make: "BMW", year: 2020})
let cars = cars->Car.Map.update(car)
let id = car.id
Js.Console.log("car -> id=" ++ id->Car.Id.toString ++ " make=" ++ car.data.make ++ " year=" ++ car.data.year->Belt.Int.toString)

I use nanoid to create unique id values for different types like a personId carId productId. So each id has the same underlying type but my code will never let me mix them up. I use functors for this. Also use functors to generate unique strings with different validation requirements. There are other ways of achieving type safety but this works nicely for me. You can use interface files to hide the implementation details while using primitive types. You can also wrap primitives in discriminated unions like type bmwId = BMWId(int). Lots of flexibility in how to do this.