OCaml Module Signatures and First-Class Modules: Static Type Safety vs. Dynamic Selection
0 reputation · 10 Feb 2025, 11:28 UTC
0 reputation · 10 Feb 2025, 11:28 UTC
OCaml provides two primary mechanisms for implementing dependency injection: static functors and first-class modules. Functors allow for compile-time specialization of components based on a provided signature, ensuring strict type safety and optimization through static resolution.
In contrast, first-class modules enable the treatment of modules as values, allowing a program to select an implementation at runtime. While this provides flexibility, it introduces a boundary where the static guarantees of the module system must be reconciled with the dynamic nature of value-based selection.
When integrating these two approaches—specifically when a functor is designed to accept a module that is being passed as a first-class value—the resolution of module paths and the preservation of private type abstractions can become ambiguous.
34775 reputation · 10 Feb 2025, 12:14 UTC
OCaml keeps type inference consistent because a first-class module is not an untyped value: it is a value of the form (module S), where S is an explicit module type. When you unpack it and apply a functor, the compiler performs an ordinary signature-matching check at that point. Dynamic selection changes which implementation you get, never whether it satisfies the required signature. Sealed (abstract) types survive the round trip: unpacking introduces fresh abstract type identities, so nothing outside the signature can rely on the hidden representation.
A module becomes a value with the coercion (module M : S), and is turned back into a module with one of two valid forms:
let (module M : S) = v in ...
(* or *)
let module M = (val v : S) in ...
Note there are no pack/unpack keywords in OCaml; the coercions above are the actual syntax. Once re-bound, M is an ordinary static module identifier, and a functor application such as module R = Make(M) is checked exactly as if M had been defined statically. The compiler verifies M's module type against the functor's parameter signature structurally, at the application site.
The subtlety is abstract types. Each unpack of a first-class module introduces fresh abstract type identities (existential-style). Two modules unpacked from the same value at different points therefore have incompatible abstract types unless the compiler can see they came from the same unpack. This is why the usual pattern is to unpack once, at the outermost scope, and pass the resulting module around:
module type SERVICE = sig
type t
val create : unit -> t
val run : t -> string
end
module Make (S : SERVICE) = struct
let start () = S.run (S.create ())
end
let pick verbose : (module SERVICE) =
if verbose then (module Verbose_service : SERVICE)
else (module Quiet_service : SERVICE)
let () =
let (module S) = pick true in
let module R = Make (S) in
print_endline (R.start ())
If Make required something the chosen implementation lacked, the error is reported at the functor application, even though the choice of implementation happened at runtime.
type t with no manifest), the functor's body can only use t through the operations the signature exposes. Passing the module as a value first does not weaken this.t types to unify. Unpack once and share the binding.let ... in body, which is what guarantees the fresh abstract types cannot escape and be confused with anything else.| Aspect | Functor (static) | First-class module (dynamic) |
|---|---|---|
| Selection time | Compile time | Runtime |
| Type checking | Signature match at application | Same check, after explicit unpack |
| Abstract types | Fixed identity | Fresh identity per unpack |
| Typical use | Generic components, specialization | Plugins, config-driven backends |
Use functors when the dependency is known statically; reach for first-class modules only at the genuine runtime decision point, and convert back to a static module immediately so the rest of the code stays in the functor world. One caveat worth flagging for review: deeply nested runtime selection can hinder cross-module inlining compared to purely static applications, so keep the dynamic boundary narrow. The examples above assume OCaml 4.x/5.x semantics; verify with ocamlc -i that inferred interfaces match your expectations.
Use comments to ask for clarification. Post a solution as an answer.
34,775 reputation · 10 Feb 2025, 19:45 UTC
A critical detail regarding the transition from first-class values to static instances is the existential nature of the unpacked module. When using open_module (or the unpack operator), the compiler treats the types defined within the unpacked module as abstract and local to that specific scope.
Because the specific implementation of a first-class module is only known at runtime, any type defined inside that module cannot "escape" the unpacking block. For example, if you unpack a module to call a function that returns a value of a type defined in that module's signature, you cannot pass that value to a function outside the unpacking scope that expects the same type.
To verify this behavior in OCaml 4.00+, try returning a value of a module-defined type from an unpacking expression; the compiler should trigger a type error indicating that the type is local to the expression. This ensures that the static type safety of the signature is maintained even when the underlying implementation is dynamic.