How to create ID types that are agnostic on how they're created

I want to create something similar to this:

module type ID = sig
  type t
  val of_string : string -> t
  val to_string : t -> string
  val (=) : t -> t -> bool
end

module String_id = struct
  type t = string
  let of_string x = x
  let to_string x = x
  let (=) = String.(=)
end

module Username : ID = String_id
module Hostname : ID = String_id

(from Looking at Files, Modules, and Programs - Real World OCaml)

But I don’t want to create just IDs with strings, but with other types as well. How would I do that?
I found Id_types.BaseId · ocaml-base-compiler 4.13.1 · OCaml Packages but that doesn’t contain any documentation on how to create these BaseIds,

basically I want something like:

module type ID = sig
  type t
  val create : 'a -> t
  val to_string : t -> string
  val (=) : t -> t -> bool
end

module String_id = struct
  type t = string
  let create x = x
  let to_string x = x
  let (=) = String.(=)
end

But that doesn’t work val create : 'a -> 'a vs val create: 'a -> string
Or even drop the create from ID and have different implementations for creating. For example a Uuid_ID could have a val create : unit -> t that generates a random Uuid

1 Like

You need to define a new type for the create argument, as otherwise the 'a means that it should accept anything:

module type ID = sig
  type t
  type from
  val create : from -> t
  val to_string : t -> string
  val (=) : t -> t -> bool
end

Then you need to be careful not to forget what type is accepted by create:

module Username : ID = String_id

let test = Username.create "id1"
(* Error: This expression has type string but an expression
was expected of type Username.from *)

You can avoid hiding the definition of the type from with a constraint:

module Username : ID with type from = string = String_id

module Genid : ID with type from = unit = Uuid_ID
1 Like

@art-w’s answer is the best one for the question you’re asking, but I’m interested in your reason for generalising over constructors in the first place. Do you want:

  1. to have a common API for a range of implementations for the sake of providing consistency to users (who otherwise know which specific ID implementation they want);

  2. to have a common API so that it can be generalised over? (e.g. by having a functor that consumes ID implementations and knows how to build them.)

… or something else? My experience is that (2) generally doesn’t scale: constructors end up being too tied to their particular implementations to make for nice abstractions. I think (1) can work well, but for public module types I’d still recommend defining a separate, smaller module type that excludes create so that consumers don’t inherit the choice.

It’s for 1 (definitely not 2), but maybe I don’t need it. Can you show an example of what you mean with excluding create for these kind of things?

I was thinking of something like:

module Id : sig
  module type S = sig
    type t
    val of_string : string -> t
    val to_string : t -> string
    val (=) : t -> t -> bool
  end

  module type With_create = sig
    include S

    type from
    val create : from -> t
  end
end

Consumers who want to generalise over Id.S can then do so while still consuming ID generators that cannot conform to the create types shared by your pre-provided implementations. This is unlikely to be an issue for you, but it regularly is for me and so it was on my mind :slight_smile:

4 Likes