Functorising a function that handles first-class modules

I have an underlying, low-level module with the following signature (simplified from my original use-case but equivalent):

module type CACHE = sig
  …
end

module type FOOBAR = sig
  …
end

module Instantiate (F: FOOBAR) (H: Hastbl.HashedType) : CACHE

All the FOOBAR stuff is nitty-gritty details that the user should not see. So I have the following over-layer (also simplified but still equivalent):

type color = Red | Blue
type shade = Light | Dark
let foobar_of_parameter color shade =
  match color with
  | Blue -> (module Blue : FOOBAR)
  | Red -> match shade with
    | Light -> (module Pink : FOOBAR)
    | Dark -> (module Red : FOOBAR)

module type MAKE = functor (H: Hashtbl.HashedType) -> CACHE
type make = (module MAKE)

let make (c : color) (s : shade) : make =
  let module Foobar = (val foobar_of_parameter c s: FOOBAR) in
  let module M = Instantiate (Foobar) in
  (module M)

That works. Specifically, it (1) compiles without complains and runs fine, and (2) it presents the end-user with two independent parameters that fit better with the narrative of the library than the FOOBAR parameters of the low-level Instantiate.

However, it’s a bit annoying to have to mix first-level module and functors. So I wanted to make a fully-functorised version. There is a bit of boilerplate and then the core functor looks like this (again, simplified but equivalent):

module type PARAM = sig
   val c: color
   val s: shade
end

module Make (P: PARAM) (H: Hashtbl.HashedType) =
  Instantiate
     (val foobar_of_parameter P.c P.s: FOOBAR)
     (H)

However, this functor application fails with the error:
This expression creates fresh types. It is not allowed inside applicative functors.


Is it possible to have a fully-functorised version?
I’m also open to another approach that would simply expose the more intuitive parameters.

My real use case is at https://gitlab.com/nomadic-labs/ringo/-/blob/more-uniform-variants/src/ringo.ml and it’s a little bit more complicated: there is one more parameter and the instantiation functor takes one more module. Also note that some of the parameter (retention in the real case) will eventually grow to include more variants. Thus, it is preferable to avoid solutions that grow with the size of the parameter space.

2 Likes