How to create distinct uuid types AND access them from TypeScript

My code tracks objects with unique IDs like ProductId, CompanyId, PersonId. These share the same implementation but I want distinct types so I don’t mix them up. I also want to be able to work with these types from TypeScript. A simple approach is like the code below. But I don’t like having to pattern match each kind of ID before working with it and the constructors are a bit ugly. My question is whether it is possible to accomplish this same thing using include or functors. I tried (see code that follows) and although it works from ReScript I can NOT get it to work from TypeScript; no .gen.js file is built.

module UniqueId = {
  type t = string
  @genType
  let makeRandom = () => "some random id"
  @genType
  let asText = s => s
}

module PersonId = {
  @genType
  type t = PersonId(UniqueId.t)
}

module CompanyId = {
  @genType
  type t = CompanyId(UniqueId.t)
}

let bob = PersonId.PersonId(UniqueId.makeRandom())

let bobText = switch bob {
| PersonId(id) => id->UniqueId.asText
}

Here is what I’m trying to achieve. Notice how much cleaner the code is to construct and work with ID values. But the @gentype annotations are not working as I had hoped. Nothing ends up in the .gen file.

module Make = (
  P: {
    let prefix: string
  },
) => {
  @genType
  type t = string
  @genType
  let makeRandom = () => P.prefix ++ "some random id"
  @genType
  let asText = u => u
}

module PersonId = {
  include Make({
    let prefix = "p_"
  })
}

module CompanyId = Make({
  let prefix = "c_"
})

let bob = PersonId.makeRandom()
let bobText = bob->PersonId.asText

You can use Unboxed which will compile a single discriminate case to a string literal.

And since there’s only one case, you can unwrap them directly without having to switch on them

@unboxed 
type personId = PersonId(string)

let bob = PersonId("bob")

let PersonId(id) = bob

let logId = (PersonId(id)) => {
  Js.Console.log(id)
}

logId(bob)

https://rescript-lang.org/try?code=AIVwdgRg9gHgpgEwAQCgAuBPADnJOBOAzlGAJLIC8SACnESeQBSFr4CWYA5gJQp8oAbOGiTQI5JFVr0yCRgCIx83kJHTisxmwTdJoqOIR9VSAVE4SqjdQznbuuigD4kAbxRIkAKUIA6AMIkxEK+ZpxaOigAvkA

1 Like

hellos3b’s unboxed with the single-case variant is a nice solution. Especially since your original solution doesn’t do any sort of unique validation on the different types (PersonId, CompanyID, etc).

Since you specifically asked about how to do it with include or with functors, and that it also works with genType, here are a couple of options. Again, just to show a couple of other options using abstract types (eg useful if you need to do a little more work with the types other than simply wrap them), but the solution posted by @hellos3b is much simpler.

The trick is to set everything up just right so that the genType does its work…(eg, make sure to see where the @genType annotations are)

Also note that the two methods generate slightly different JS output…with the include version generating less JS output than the functor version. Whichever you think is nicer is up to you…in the case you show, either will work as you don’t need the full power of the functor to do the job.

Using include

module UniqueId: {
  type t
  @genType
  let makeRandom: unit => t
  @genType
  let asText: t => string
} = {
  type t = string
  let makeRandom = () => "some random id"
  let asText = s => s
}

module PersonId: {
  include module type of UniqueId
} = {
  include UniqueId
}

module CompanyId: {
  include module type of UniqueId
} = {
  include UniqueId
}

let bob = PersonId.makeRandom()
let bobId = PersonId.asText(bob)

let acmeCo = CompanyId.makeRandom()
let acmeCoId = CompanyId.asText(acmeCo)

// Neither will work.
// let _x = PersonId.asText(acmeCo)
// let _y = CompanyId.asText(bob)

Note: do not do this (type strengthening), as the types will not be distinct (source).

  include module type of {
    include UniqueId
  }

Using functors

module UniqueId = {
  module type S = {
    type t

    @genType
    let makeRandom: unit => t
    @genType
    let asText: t => string
  }

  module Make = (): S => {
    type t = string

    let makeRandom = () => "some random ID"
    let asText = t => t
  }
}

module PersonId = {
  @genType
  include UniqueId.Make()
}
module CompanyId = {
  @genType
  include UniqueId.Make()
}

let bob = PersonId.makeRandom()
let bobId = PersonId.asText(bob)

let acmeCo = CompanyId.makeRandom()
let acmeCoId = CompanyId.asText(acmeCo)

// Neither will work.
// let _x = PersonId.asText(acmeCo)
// let _y = CompanyId.asText(bob)

Note: Even though it’s nicer looking, do not do either of these as genType won’t do it’s thing.

// Don't do this...
module PersonId = UniqueId.Make()
module CompanyId = UniqueId.Make()

// Or this...
@genType
module PersonId = UniqueId.Make()
@genType
module CompanyId = UniqueId.Make()

Note: by the way, I didn’t check if the genType generated files were actually correct or not…just that they were generated…so this exercise could be purely academic! (Same caveat as I mention in your other question…)

1 Like

I think this is equivalent to my first solution not using functors/include except the specific ID types are defined using @unboxed like this…

module PersonId = {
  @unboxed
  type t = PersonId(UniqueId.t)
}

@unboxed probably makes working with these types from TypeScript simpler. I can use the UniqueId functions on all PersonId, CompanyId, etc. without unwrapping them first. I lose type safety in TypeScript - confusing PersonId and CompanyId for example - but in this case I don’t really care.

I understand that single case variants can be destructured without switch statements. This is useful when you define a function that takes one of these types as a parameter. But if I’ve got a specific PersonId.t that I want to work with I don’t see how this helps much. Is there some way to simplify the second example below? It works but is less easy to understand than the switch case.

let bob = PersonId.PersonId(UniqueId.makeRandom())

// using switch
let bobText1 = switch bob {
| PersonId(id) => id->UniqueId.asText
}

// destructure single case
let bobText2 = bob->((PersonId.PersonId(p)) => p->UniqueId.asText)

The constructors for PersonId, CompanyId, etc. are still not as nice as the functor approach.

I think the Functor approach you’ve got here might work! :grinning: Haven’t looked at the other solution. I need to spend more time looking at it and see how it differs from what I attempted. I learned yesterday that gentype does not understand the module language, which seems like it could be a big limitation, and so I don’t yet understand what works and what does not. Will try to get my head around this all this morning.

I wonder how big of an issue it would be in practice…do these things and you should be able to avoid sticking genType annotations in your functor:

  • annotate the type signatures returned by functors with genType
  • annotate the include Blah.Make(...) with genType at the location in which you generating the modules using your functor
  • move any calls annotated with genType.import inside your functor to a submodule in the functor, then move that submodule out of the functor, and include it from within the functor (like this…link to your other question).
    • Of course…could be trickier once you have parameters in your functors, unlike the one shown here.

I have a feeling you could get a long way doing those things.


By the way, looking at the code you linked in your other post (here it is in case anyone wants to take a look), I’m not really sure why you want/need to use functors in the way you’re using them. Do you just want to reduce the boilerplate of defining the unique ID functions, or are you eventually going to parameterize things in a way that the implementation of UserId, CompanyId, WhateverId, actually change?

It probably won’t be a problem in practice once I learn and understand the rules you are proposing. Those rules below are exactly what I need. Just saw them now and will try to use them and see if I can make it all work. I don’t specifically need functors. UserId, CompanyId, etc. will have exactly the same implementations. Maybe using functors with no configuration parameter is pointless? From the ReScript side I want to make sure they are unique types. I obviously want to remove all boilerplate. My other goals are to make it super easy to construct these ID values, like let bob = PersonId.makeRandom() not let bob = PersonId.PersonId(UniqueId.makeRandom()), and to work with them, like id->PersonId.getText.

My real code is a little more complex than these examples. I have a core ValidatedPrimitive module that wraps strings, ints, floats, etc. with a validation function, equality testing, serialization. And then on top of that I’ve got things like BoundedInt, LengthLimitedString, UniqueId. I instantiate UniqueId into PersonId, CompanyID, etc. All those layers reduce my need for repetitive code. But it makes it hard for me to figure out where the gentype stuff is not doing what I expect.

1 Like

I see what you’re saying. Yeah, definitely not trying to dissuade you from functors, but I do think it is a good idea to think about whether they help simplify your code, or make it more obscure. Like, they’re cool and sometimes you need to use them, but as you’ve now seen, they are a different level of the language and can increase the overall complexity of what you’re trying to do. (Plus they generally make you’re generated JS more obscure as well, if that’s something you care about.) So, just consider the tradeoffs of using them when they aren’t strictly necessary, is all I’m trying to say.

1 Like

I really felt like I was making progress here. Everything was compiling fine and works from ReScript. But it is not possible to access these exported types from TypeScript. There is a problem with the .gen file. For the functors code you provided, this is part of the gen file. Notice the double .UniqueId.UniqueId.makeRandom. This is wrong. It won’t import, and complains about can’t find makeRandom on undefined. If I change it to .UniqueId.makeRandom it works. Actually, maybe none of those functions like UniqueId_S_makeRandom are necessary/used. My code works when I import UniqueId and call methods on that. I’m not sure why those extra functions are in the .gen and how to stop them from being generated.

...
export abstract class CompanyId_t { protected opaque!: any }; /* simulate opaque types */
...
export const UniqueId_S_makeRandom: () => UniqueId_S_t = FunctorsBS.UniqueId.UniqueId.makeRandom;
...
export const UniqueId: {
  S: { asText: (_1: UniqueId_S_t) => string; makeRandom: () => UniqueId_S_t };
} = FunctorsBS.UniqueId;

I have been noticing that sometimes you have to put the module type inline. This works. But if the module type is first defined as module type S and then used, it doesn’t. Very confusing.

  module Make = (): {
    type t
    @genType
    let makeRandom: unit => t
    @genType
    let asText: t => string
  } => {
    type t = string
    let makeRandom = () => "some random ID"
    let asText = t => t
  }
}

Ok here is my final code, simplified a bit. It shows how to generate some types with functors and use genType so the types and functions are available in TypeScript. This was very tricky. Many other solutions generated working code from within ReScript but the generated .gen.tsx files were broken. One gotcha is that sometimes you have to provide module types inline; I don’t know why. Also there is a case where the parameter to a functor has to be inlined or it doesn’t work.

The code below defines:

  • Primitive - A wrapper around a string, int, float with a validation function.
  • PositiveInteger - An instance of a Primitive that only accepts positive values.
  • UniqueId - A PositiveInteger with a makeRandom function; a simplified uuid
  • PersonId and CompanyId - Instances of UniqueId for different object types.
module Primitive = {
  module type Configuration = {
    type domain
    let validate: (. domain) => option<domain>
  }

  // Originally I had a separate functor return type and referenced it in
  // the functor. This caused a broken .gen file. So I inlined the return
  // type instead.
  module Make = (P: Configuration): {
    type t
    @genType
    let make: P.domain => option<t>
    @genType
    let value: t => P.domain
  } => {
    type t = P.domain
    let make = P.validate(. _)
    let value = v => v
  }
}

module PositiveInteger = {
  // This is accessible from TypeScript! What I'm doing:
  // 1. "include" the result of the functor inside the module, rather than
  //    the more concise module X = Primitive.Make(...)
  // 2. Preface "include" with @genType
  // 3. Put @genType annotations in the functor return type
  // 4. Provide the functor parameter inline, not as a separate module.
  @genType
  include Primitive.Make({
    type domain = int
    let validate = (. v) =>
      if v > 0 {
        Some(v)
      } else {
        None
      }
  })

  // This approach below does NOT work. The functor configuration has to be
  // supplied inline. Weird!
  //
  // module Config: Primitive.Configuration = {
  //   type domain = int
  //   let validate = (. v) =>
  //     if v > 0 {
  //       Some(v)
  //     } else {
  //       None
  //     }
  // }
  // @genType
  // include Primitive.Make(Config)
}

module UniqueId: {
  include module type of PositiveInteger
  @genType
  let makeRandom: unit => t
} = {
  include PositiveInteger
  let makeRandom = () => Js.Math.random_int(1, 1000)->make->Belt.Option.getExn
}

// This looks equivalent to the above, except the type of the module is
// defined separately. But GenType output is broken when doing it this way.
// The type of the module must be supplied inline.
//
// module type UniqueIdType = {
//   include module type of PositiveInteger
//   @genType
//   let makeRandom: unit => t
// }
//
// module UniqueId: UniqueIdType = {
//   @gentype
//   include PositiveInteger
//   let makeRandom = () => Js.Math.random_int(1, 1000)->make->Belt.Option.getExn
// }

module PersonId: {
  include module type of UniqueId
} = {
  include UniqueId
}

module CompanyId: {
  include module type of UniqueId
} = {
  include UniqueId
}

// Do not do this instead of the above, because then PersonId.t and CompanyId.t
// will be the same type
//
// module PersonId = {
//   include UniqueId
// }
//
// module CompanyId = {
//   include UniqueId
// }

Yeah, that does seem like a lot of trial and error to get it to work just right…do you have a public GitHub to link here for an example? (May be useful for future readers of the thread…)