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.