Runtime Logger Selection in OCaml Using First-Class Modules
Package an OCaml module as a value and choose a logging backend at runtime without losing compile-time type checks.
05 Oct 2025, 12:04 UTC

The problem: modules are compile-time, but your config is not
In OCaml, a module is usually a static unit compiled into your program. If you write module Log = Console, the choice is fixed when you compile. That works until your operations team wants to switch to file logging by editing a config file instead of triggering a new build. You need a way to treat a module as a value, inspect it at runtime, and pass it around like any other value.
OCaml's answer is first-class modules. The expression (module M) packs a module into a value. Later, let module M = (val v) in unpacks that value back into a module. The compiler checks the implementation against a shared module type before packing, so a mismatched backend is a compile error, not a production surprise.
Define the contract with a module type
A module type is an interface that lists the operations an implementation must provide. For a pluggable logger, the contract might look like this:
module type LOGGER = sig
val log : string -> unit
end
Any module that implements log with that exact type can satisfy LOGGER. The concrete type of the module is hidden after packing, which means callers depend on the signature, not on a particular backend.
Worked example: two backends and a runtime selector
Create a directory, for example ocaml-logger-demo, and add two implementation modules:
module Console : LOGGER = struct
let log message = print_endline ("[console] " ^ message)
end
module File : LOGGER = struct
let log message =
let ch = open_out "[logfile]" in
output_string ch ("[file] " ^ message ^ "\n");
close_out ch
end
Replace [logfile] with a path your user can write to. The selector receives a packed module value and calls log through the unpacked module:
let run (logger : (module LOGGER)) =
let module L = (val logger) in
L.log "startup complete"
A main program can choose between them based on an environment variable or argument:
let () =
let backend = if Sys.getenv "LOG_BACKEND" = "file" then
(module File : LOGGER)
else
(module Console : LOGGER) in
run backend
The annotation : LOGGER after the pack expression tells the compiler which signature to use. Without it, OCaml may not know how to interpret the packed value.
Dune build file
In the project root, a minimal dune stanza looks like this:
(executable
(public_name logger-demo)
(name main)
(libraries))
Run ocaml -version and dune --version in the project directory to confirm the toolchain. Then compile with dune build. No special Dune configuration is required for first-class modules; the feature is part of the core language. Execute the binary with dune exec ./logger-demo.exe on Windows or dune exec ./logger-demo on Linux and macOS.
Set LOG_BACKEND=file to use the file backend, or leave it unset for console output. You should see the corresponding prefix in the terminal or in the chosen log file. If the file path is unwritable, the program raises an exception at the open_out call; check file permissions and the path placeholder before running.
Trade-off: type safety comes with indirection
Packing erases the concrete module identity. The compiler treats types inside the packed module as abstract, so you cannot pattern-match on backend-specific constructors after unpacking unless you add explicit type equalities. Stack traces also show the module boundary less clearly, which can make debugging harder.
If the backend choice is known at compile time, a functor is usually more ergonomic and gives the optimizer more information. Reserve first-class modules for cases where the implementation genuinely depends on runtime input, such as environment variables, feature flags, or plugin discovery.
Practical check before you ship
- Run
ocaml -versionanddune --version; syntax for first-class modules has changed across OCaml releases. - Run
dune buildand confirm there are no type errors when switching theLOG_BACKENDvalue. - Add a temporary
print_endlineinside each backend'slogfunction to verify the correct implementation is invoked. - Remove the temporary prints and rebuild to ensure the final binary behaves as expected.
First-class modules solve runtime implementation selection without abandoning OCaml's type system. Use them when the flexibility justifies the extra abstraction, and verify with your installed compiler version before adopting the pattern in production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.