Skip to content
First-Class Modules
step 1/7

Reading — step 1 of 7

Learn

~3 min readGADTs, First-Class Modules, Effects

OCaml's modules are usually compile-time constructs. First-class modules lift them to values — pass them around, store them, return them from functions. The OCaml manual treats this as a power feature for plugin systems and dependency injection.

Module types and modules

module type Comparable = sig
    type t
    val compare : t -> t -> int
end

module IntCmp : Comparable with type t = int = struct
    type t = int
    let compare a b = compare a b
end

module StringCmp : Comparable with type t = string = struct
    type t = string
    let compare a b = String.compare a b
end

Normally these are compile-time types. They can also be used as VALUES.

First-class module syntax

Use (module M) to package a module as a value:

let m1 : (module Comparable with type t = int) = (module IntCmp)
let m2 : (module Comparable with type t = string) = (module StringCmp)

The parens around module M are crucial. The type (module SIG) is the type of first-class module values.

Unpacking — using a first-class module

let sort (type a) (m : (module Comparable with type t = a)) (xs : a list) =
    let module M = (val m) in
    List.sort M.compare xs
  • (type a) — locally abstract type at the function head
  • (val m) — extracts the module from the value
  • let module M = ... in ... — binds it locally

Now sort (module IntCmp) [3; 1; 2] works.

Use case: dependency injection

module type Logger = sig
    val log : string -> unit
end

module ConsoleLogger : Logger = struct
    let log msg = print_endline ("LOG: " ^ msg)
end

module SilentLogger : Logger = struct
    let log _ = ()
end

let do_work (logger : (module Logger)) =
    let module L = (val logger) in
    L.log "starting";
    L.log "finishing"

let () =
    do_work (module ConsoleLogger);
    do_work (module SilentLogger)

Different logger implementations selected at runtime. Common pattern for testing (silent logger in tests, real one in prod).

When to use first-class modules

  • Plugin systems — load modules from config
  • Dependency injection — swap implementations for tests
  • Strategy pattern — pick algorithm at runtime
  • Heterogeneous collections — list of modules implementing the same signature

vs Functors

Functors (covered earlier) are COMPILE-TIME parameterized modules. First-class modules are RUNTIME values.

(* Functor: compile-time *)
module SortByCompare (M : Comparable) = struct
    let sort xs = List.sort M.compare xs
end

(* First-class: runtime *)
let sort (m : (module Comparable with type t = _)) xs = ...

Functors give you stronger compile-time guarantees and zero runtime cost. First-class modules let you select dynamically. Pick based on whether the choice is known at compile or run time.

Type sharing

The with type t = INT constraint exposes the underlying type:

(module Comparable with type t = int)   (* type t exposed as int *)
(module Comparable)                       (* type t is abstract *)

Without the with type constraint, you can't pass values to compare — the type is abstract.

Common mistakes

  • Forgetting (module ...) parens — required for the first-class form.
  • Forgetting (val m) to unpack — using m directly as a module gives a type error.
  • Type sharing — without with type t = ACTUAL, the abstract type is unusable.
  • Reaching for first-class modules when functors suffice — compile-time is stronger when applicable.
  • (type a) annotation forgotten — needed when the function is generic in the module's type.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…