OCaml Module 7 – Modules, signatures and functors
Modules, files and namespaces
How each OCaml .ml file becomes a module, how dune wraps a library's modules, and when to use open, local opens, include and module aliases.
What you will learn
- Explain how each .ml file becomes a module and how dune names the modules of a library
- Define nested modules and reach their items through paths and module aliases
- Choose between open, let open, M.( ... ) and include for a given piece of code
- Avoid name clashes with qualified names, open! and warnings 44 and 45
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how each .ml file becomes a module and how dune names the modules of a library
- Define nested modules and reach their items through paths and module aliases
- Choose between open, let open, M.( ... ) and include for a given piece of code
- Avoid name clashes with qualified names, open! and warnings 44 and 45
Before you start
On this page
Two files, vector.ml and shape.ml, are already two modules, Vector and Shape, and compiled in the wrong order the
second stops at its third line with Error: Unbound module Vector. Put the same files in a dune library called
geometry and they move into its namespace, so a program outside it must write Geometry.Vector.make; and in a third
program of this lesson one careless open made close 1.0 1.005 answer false without a single warning.
You will explain how a file becomes a module and what dune names it, reach modules nested inside a file through paths
and aliases, choose between open, let open, M.( ... ) and include, and keep an open from hiding a name you
meant, with qualified names, open! and warnings 44 and 45.
Every .ml file is already a module
The geometry library of this lesson has two files. vector.ml defines a record type t for a point on a plane and the
functions make, add, scale, length and to_string; shape.ml defines circles and squares and uses Vector.t
for their centres and corners. Neither file says module Vector = ... anywhere:
lib/vector.ml · geometry/lib/vector.ml
(* vector.ml: points and moves on a plane. The file is the module Vector. *)
type t = { x : float; y : float }
let make x y = { x; y }
let add a b = { x = a.x +. b.x; y = a.y +. b.y }
let scale k v = { x = k *. v.x; y = k *. v.y }
let length v = Float.sqrt ((v.x *. v.x) +. (v.y *. v.y))
let to_string v = Printf.sprintf "(%g, %g)" v.x v.y lib/shape.ml · geometry/lib/shape.ml
(* shape.ml: shapes on the plane. Inside the library, Vector needs no prefix. *)
type t =
| Circle of Vector.t * float (* centre and radius *)
| Square of Vector.t * float (* lower-left corner and side *)
let area = function
| Circle (_, r) -> Float.pi *. r *. r
| Square (_, side) -> side *. side
let centre = function
| Circle (c, _) -> c
| Square (corner, side) -> Vector.add corner (Vector.make (side /. 2.) (side /. 2.)) Each file is a module: A named collection of definitions (values, types, exceptions and other modules), reached from outside with a dot, as in List.length. Every .ml file is a module named after the file. just by existing, and the compiler names it after the file. Compile the two by hand with ocamlc, shape.ml first, then load both into the toplevel:
from session import files, run, toplevel, write
# vector.ml and shape.ml, the two files of the geometry library (examples/geometry/lib/), compiled by hand. Each file
# is a module named after it, and shape.ml uses Vector, so it compiles only once vector.ml has been compiled. ocamlc -i
# prints the interface the compiler inferred for Shape. A file name that is not a module name gives warning 24. The
# toplevel then loads both compiled modules (the .cmo files on its command line) and calls them by name.
files("geometry/lib/vector.ml=vector.ml", "geometry/lib/shape.ml=shape.ml")
run("""
!ocamlc -c shape.ml
ocamlc -c vector.ml
ocamlc -c shape.ml
ls
ocamlc -i shape.ml
""")
write("my-shapes.ml", "let n = 1\n")
run("""
ocamlc -c my-shapes.ml
ls *.cmi
""")
toplevel("""
Vector.length (Vector.make 3. 4.);;
Shape.area (Shape.Square (Vector.make 0. 0., 3.));;
Shape.centre (Shape.Square (Vector.make 1. 1., 3.));;
""", args=("vector.cmo", "shape.cmo")) Output
$ ocamlc -c shape.ml
File "shape.ml", line 3, characters 14-20:
3 | | Circle of Vector.t * float (* centre and radius *)
^^^^^^
Error: Unbound module Vector
[exit status 2]
$ ocamlc -c vector.ml
$ ocamlc -c shape.ml
$ ls
shape.cmi
shape.cmo
shape.ml
vector.cmi
vector.cmo
vector.ml
$ ocamlc -i shape.ml
type t = Circle of Vector.t * float | Square of Vector.t * float
val area : t -> float
val centre : t -> Vector.t
$ ocamlc -c my-shapes.ml
File "my-shapes.ml", line 1:
Warning 24 [bad-module-name]: bad source file name: My-shapes is not a valid module name.
$ ls *.cmi
my-shapes.cmi
shape.cmi
vector.cmi
$ ocaml vector.cmo shape.cmo
OCaml version 5.5.1
Enter #help;; for help.
# Vector.length (Vector.make 3. 4.);;
- : float = 5.
# Shape.area (Shape.Square (Vector.make 0. 0., 3.));;
- : float = 9.
# Shape.centre (Shape.Square (Vector.make 1. 1., 3.));;
- : Vector.t = {Vector.x = 2.5; y = 2.5}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 files_session.py
Compiled first, shape.ml stopped at line 3 with Unbound module Vector and exit status 2: nothing called Vector had
been compiled yet. Once vector.ml was compiled, the same command passed, and each file left two results under its own
base name: vector.cmi, the compiled interface that other files are checked against, and vector.cmo, the bytecode.
ocamlc -i shape.ml printed the interface the compiler inferred for Shape, in which centre returns a Vector.t.
The manual states the rule: the compiler takes the source file’s base name, drops the folder and the suffix, and
capitalises the first letter, so mylib/misc.ml is the module Misc. Each such file is a
compilation unit: A source file compiled on its own into a module: vector.ml gives the module Vector, its compiled interface vector.cmi, against which other files are checked, and its compiled code vector.cmo., which the manual says behaves roughly like
module Vector : sig ... end = struct ... end written around the file’s contents. ocaml vector.cmo shape.cmo started
a toplevel with both units loaded, and they answered by name: Vector.length (Vector.make 3. 4.) is 5., a square of
side 3 has area 9., and the centre of the square whose corner is at (1, 1) is {Vector.x = 2.5; y = 2.5}.
A file name that is not a module name
Wrong: my-shapes.ml. It compiled with Warning 24 [bad-module-name] and still wrote my-shapes.cmi, but no other file
can write My-shapes, so the module cannot be used. Right: my_shapes.ml, the module My_shapes.
A file can also come with an interface file, vector.mli, that decides what other files may see of it; the next lesson, Signatures, abstract types and .mli files, is about those.
dune puts a library’s modules under the library’s name
In a dune project the same two files sit in lib/, whose dune file holds the stanza: One parenthesised declaration in a dune file, such as (library (name ledger)) or (test (name test_ledger) (libraries ledger)). More in “Your first dune project and editor setup”
(library (name geometry)), and bin/main.ml lists geometry among its libraries. From outside the library, every
module is reached through the library’s name:
(* main.ml: outside the library, every module is reached through Geometry. *)
let () =
let ring = Geometry.Shape.Circle (Geometry.Vector.make 0. 0., 2.) in
let tile = Geometry.Shape.Square (Geometry.Vector.make 1. 1., 3.) in
Printf.printf "circle area %.2f\n" (Geometry.Shape.area ring);
Printf.printf "square area %.2f\n" (Geometry.Shape.area tile);
print_endline (Geometry.Vector.to_string (Geometry.Shape.centre tile)) from session import files, run
# The same two files as a dune library named geometry (examples/geometry/), used by bin/main.ml through the paths
# Geometry.Vector and Geometry.Shape. The listing shows the names dune gives the compiled modules, the module file
# geometry.ml-gen that dune writes for the library, and the command that compiles lib/shape.ml.
files(
"geometry/dune-project",
"geometry/lib/dune",
"geometry/lib/vector.ml",
"geometry/lib/shape.ml",
"geometry/bin/dune",
"geometry/bin/main.ml",
)
run("""
cd geometry
dune build
dune exec ./bin/main.exe
ls _build/default/lib/.geometry.objs/byte
cat _build/default/lib/geometry.ml-gen
dune rules lib/.geometry.objs/byte/geometry__Shape.cmo | tr -s ' \\n' ' ' | grep -o '(run [^)]*'
""") Output
$ cd geometry $ dune build $ dune exec ./bin/main.exe circle area 12.57 square area 9.00 (2.5, 2.5) $ ls _build/default/lib/.geometry.objs/byte geometry.cmi geometry.cmo geometry.cmt geometry__Shape.cmi geometry__Shape.cmo geometry__Shape.cmt geometry__Vector.cmi geometry__Vector.cmo geometry__Vector.cmt $ cat _build/default/lib/geometry.ml-gen (* generated by dune *) (** @canonical Geometry.Shape *) module Shape = Geometry__Shape (** @canonical Geometry.Vector *) module Vector = Geometry__Vector $ dune rules lib/.geometry.objs/byte/geometry__Shape.cmo | tr -s ' \n' ' ' | grep -o '(run [^)]*' (run ~/.opam/5.5.1/bin/ocamlc.opt -short-paths -keep-locs -warn-error +a -g -bin-annot -bin-annot-occurrences -I lib/.geometry.objs/byte -no-alias-deps -opaque -open Geometry -o lib/.geometry.objs/byte/geometry__Shape.cmo -c -impl lib/shape.ml
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wrapped_session.py
The program printed circle area 12.57, square area 9.00 and the centre (2.5, 2.5), and dune worked out the
compile order itself. The listing shows what it did with the files. vector.ml became the module Geometry__Vector and
shape.ml Geometry__Shape, in files named geometry__Vector and geometry__Shape: the library’s name, two underscores, the
module’s name. Then dune wrote one more module of its
own, geometry.ml-gen, with the lines module Shape = Geometry__Shape and module Vector = Geometry__Vector, and
compiled it as the module Geometry. The last command printed how shape.ml itself was compiled, with -open Geometry:
that is why shape.ml can write Vector.t while bin/main.ml must write Geometry.Vector.t. Step through the whole path:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Two files, one library. lib/ holds vector.ml and shape.ml, and lib/dune says (library (name geometry)). Nothing in either file mentions geometry: the library's name comes from the stanza alone.
- Each file becomes a module. dune compiles vector.ml as the module Geometry__Vector and shape.ml as Geometry__Shape, the library name, two underscores and the module name; the compiled files in .geometry.objs/byte start with geometry__.
- Short names inside. Every file of the library is compiled with -open Geometry. That is why shape.ml writes Vector.t and Vector.add without any prefix and still compiles.
- The wrapper module. dune also writes geometry.ml-gen, two aliases, module Shape = Geometry__Shape and module Vector = Geometry__Vector, and compiles it as the module Geometry, the one name meant for code outside the library.
- The path outside. bin/main.ml lists geometry in its libraries and writes Geometry.Shape.area and Geometry.Vector.make. The program printed circle area 12.57, square area 9.00 and (2.5, 2.5).
- No short names outside. A bin/main.ml that writes Vector alone does not compile: dune stopped with Unbound module Vector, because outside the library only Geometry is a top-level module.
- With (wrapped false). Adding (wrapped false) to lib/dune skips the wrapper: the compiled files are vector.cmo and shape.cmo, Vector and Shape become top-level modules, and the same short-name program printed (1, 2).
The library’s name has become a namespace for its modules, the way a module is a namespace for its values: OCaml.org’s
guide to dune libraries says wrapping lets several modules share a name in one project as long as they sit in
different libraries. The double underscore is chosen on purpose, the manual explains: when Lib.FooBar is an alias of
Lib__FooBar, the compiler prints the dotted name, so messages about the library say Geometry.Vector. The next session
shows the other side of wrapping, a program outside the library that uses the short names, before and after
(wrapped false):
from session import files, run, write
# Outside a wrapped library its modules have no short names: a bin/main.ml that writes Vector alone does not compile.
# With (wrapped false) in lib/dune, dune writes no Geometry module and Vector and Shape become top-level modules.
files("geometry/dune-project", "geometry/lib/dune", "geometry/lib/vector.ml", "geometry/lib/shape.ml", "geometry/bin/dune")
write("geometry/bin/main.ml", "let () = print_endline (Vector.to_string (Vector.make 1. 2.))\n")
run("""
cd geometry
cat bin/main.ml
!dune build
""")
write("geometry/lib/dune", "(library\n (name geometry)\n (wrapped false))\n")
run("""
cd geometry
cat lib/dune
dune build
dune exec ./bin/main.exe
ls _build/default/lib/.geometry.objs/byte
""") Output
$ cd geometry
$ cat bin/main.ml
let () = print_endline (Vector.to_string (Vector.make 1. 2.))
$ dune build
File "bin/main.ml", line 1, characters 24-30:
1 | let () = print_endline (Vector.to_string (Vector.make 1. 2.))
^^^^^^
Error: Unbound module Vector
[exit status 1]
$ cd geometry
$ cat lib/dune
(library
(name geometry)
(wrapped false))
$ dune build
$ dune exec ./bin/main.exe
(1, 2)
$ ls _build/default/lib/.geometry.objs/byte
shape.cmi
shape.cmo
shape.cmt
vector.cmi
vector.cmo
vector.cmt
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unwrapped_session.py
With wrapping on, Vector alone gave Error: Unbound module Vector. With (wrapped false) in lib/dune, dune wrote no
Geometry module and kept the files’ own names, vector.cmo and shape.cmo, so Vector became a top-level module and the
program printed (1, 2). dune’s documentation keeps wrapping as the default and calls it highly recommended: top-level
module names must be unique when a program is linked, so a library that puts names such as Vector at the top level
can collide with another library’s. It describes (wrapped false) as meant for libraries that already prefix their
module names by hand, and for porting older projects to dune.
Key idea
Outside a dune library its modules are Library.Module; inside it they are plain Module, because dune compiles every file with -open of the library module it writes.
Checkpoint
Check: Each file is a module · dune's wrapped libraries
Modules inside a file are reached by paths
A file can define modules of its own with module Name = struct ... end. units.ml holds two, Distance and Time, each
with a type t and a conversion, and a function speed that takes one of each:
(* units.ml: one file, the module Units, with two modules of its own. *)
module Distance = struct
type t = float
let of_km km = km *. 1000.
let to_string m = Printf.sprintf "%.0f m" m
end
module Time = struct
type t = float
let of_minutes m = m *. 60.
end
let speed (d : Distance.t) (t : Time.t) = d /. t The session compiles it, prints the interface the compiler inferred, and uses its items from the toplevel:
from session import files, run, toplevel
# units.ml (examples/units.ml) defines two modules, Distance and Time, so the file's module Units has a module inside
# it for each. ocamlc -i prints the nesting. The toplevel reaches each item by its full path, gives Units.Distance a
# second name with a module alias, and shows that Distance alone is not in scope.
files("units.ml")
run("""
ocamlc -c units.ml
ocamlc -i units.ml
""")
toplevel("""
Units.Distance.of_km 2.5;;
Units.Distance.to_string (Units.Distance.of_km 2.5);;
Units.speed (Units.Distance.of_km 6.) (Units.Time.of_minutes 25.);;
module D = Units.Distance;;
D.to_string (D.of_km 1.2);;
let lap : D.t = Units.Distance.of_km 0.4;;
Distance.of_km 1.;;
""", args=("units.cmo",)) Output
$ ocamlc -c units.ml
$ ocamlc -i units.ml
module Distance :
sig
type t = float
val of_km : float -> float
val to_string : float -> string
end
module Time : sig type t = float val of_minutes : float -> float end
val speed : Distance.t -> Time.t -> float
$ ocaml units.cmo
OCaml version 5.5.1
Enter #help;; for help.
# Units.Distance.of_km 2.5;;
- : float = 2500.
# Units.Distance.to_string (Units.Distance.of_km 2.5);;
- : string = "2500 m"
# Units.speed (Units.Distance.of_km 6.) (Units.Time.of_minutes 25.);;
- : float = 4.
# module D = Units.Distance;;
module D = Units.Distance
# D.to_string (D.of_km 1.2);;
- : string = "1200 m"
# let lap : D.t = Units.Distance.of_km 0.4;;
val lap : D.t = 400.
# Distance.of_km 1.;;
Line 1, characters 0-8:
1 | Distance.of_km 1.;;
^^^^^^^^
Error: Unbound module Distance
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 paths_session.py
ocamlc -i printed the nesting: the interface of Units holds module Distance : sig ... end and module Time, and
val speed : Distance.t -> Time.t -> float. From outside, a path names each step in turn, so
Units.Distance.of_km 2.5 is the value of_km of the module Distance of the module Units: it gave 2500..
Units.speed (Units.Distance.of_km 6.) (Units.Time.of_minutes 25.) gave 4., 6,000 metres in 1,500 seconds.
Long paths are exact but heavy. module D = Units.Distance gives the module a second name, a
module alias: A second name for an existing module, written module D = Units.Distance. The compiler treats both names as the same module, so D.t and Units.Distance.t are the same type.. The toplevel printed module D = Units.Distance instead of a copy of the
signature, D.to_string (D.of_km 1.2) gave "1200 m", and lap, declared as a D.t, printed as 400.. The manual
explains that the compiler treats the two paths as equal, and that with -no-alias-deps, the flag in dune’s compile
command above, an alias adds no link-time dependency until code uses something through it. The last phrase shows what
a path does not do for you: outside units.ml, Distance alone is Unbound module Distance. Follow the same paths in
the figure:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The file is the module. ocamlc -c units.ml wrote units.cmi. When the toplevel, or another file, writes Units, the compiler reads that compiled interface: the module Units is the file units.ml.
- Two modules inside. units.ml defines Distance and Time with module ... = struct ... end, so Units holds two modules of its own, each with a type t, beside the function speed. ocamlc -i printed both inside the interface of Units.
- A path, one step at a time. Units.Distance.of_km names the module Units, then the module Distance inside it, then the value of_km. Units.Distance.of_km 2.5 gave 2500., and to_string turned that into "2500 m".
- Two paths in one call. Units.speed (Units.Distance.of_km 6.) (Units.Time.of_minutes 25.) gave 4.: 6,000 metres in 1,500 seconds is 4 metres a second. speed takes a Distance.t and a Time.t, as its type said.
- A second name. module D = Units.Distance makes D an alias: the toplevel printed module D = Units.Distance, not a copy of the signature. D.to_string (D.of_km 1.2) gave "1200 m", and a value written as D.t is a Units.Distance.t.
- No path, no module. Nothing opened Units, so Distance on its own is unknown outside units.ml: the toplevel answered Unbound module Distance. The full path or the alias reaches it.
Each step of a path picks a module inside the one before it, from the compiled file down to the value, and an alias starts the same walk from a shorter name.
A local open makes names short for one expression
Opening a module makes its names usable without the prefix. A local open: Opening a module for one expression only, written M.( ... ) or let open M in ..., so the names of M are short inside that expression and need the prefix everywhere else. does it for one
expression only, in the two spellings the manual gives for the job: SMap.( ... ) and let open SMap in ..., which
produce the same binding.
SMap here is the module of maps with string keys that Map.Make (String) returns, as in
the maps and sets lesson. Map.Make is a functor: A module that takes one or more modules as arguments and returns a new module. Map.Make is one: given a module with a type t and a compare function, it returns a module of maps whose keys have type t. More in “Maps and sets”;
the functors lesson explains how one works. The session defines SMap
in defs.ml before the first prompt and loads units.cmo for the last two phrases:
from session import files, run, toplevel, write
# SMap.( ... ) and let open SMap in ... make SMap's names short inside one expression only; an open SMap at the top
# level keeps them short to the end. Units.( ... ) works in a type as well (OCaml 5.2). defs.ml defines
# SMap = Map.Make (String) before the first prompt (-init prints nothing), and the toplevel loads units.cmo.
files("units.ml")
write("defs.ml", "module SMap = Map.Make (String)\n")
run("""
ocamlc -c units.ml
cat defs.ml
""")
toplevel("""
SMap.(empty |> add "pen" 3 |> add "ink" 2 |> bindings);;
let stock = let open SMap in empty |> add "pen" 3 |> add "ink" 2;;
SMap.(find "ink" stock, mem "nib" stock, cardinal stock);;
cardinal stock;;
open SMap let restock = add "nib" 10 stock;;
cardinal restock;;
type leg = Units.(Distance.t * Time.t);;
let warm_up : leg = Units.(Distance.of_km 1.2, Time.of_minutes 8.);;
""", args=("-init", "defs.ml", "units.cmo")) Output
$ ocamlc -c units.ml
$ cat defs.ml
module SMap = Map.Make (String)
$ ocaml -init defs.ml units.cmo
OCaml version 5.5.1
Enter #help;; for help.
# SMap.(empty |> add "pen" 3 |> add "ink" 2 |> bindings);;
- : (SMap.key * int) list = [("ink", 2); ("pen", 3)]
# let stock = let open SMap in empty |> add "pen" 3 |> add "ink" 2;;
val stock : int SMap.t = <abstr>
# SMap.(find "ink" stock, mem "nib" stock, cardinal stock);;
- : int * bool * int = (2, false, 2)
# cardinal stock;;
Line 1, characters 0-8:
1 | cardinal stock;;
^^^^^^^^
Error: Unbound value cardinal
# open SMap let restock = add "nib" 10 stock;;
val restock : int SMap.t = <abstr>
# cardinal restock;;
- : int = 3
# type leg = Units.(Distance.t * Time.t);;
type leg = Units.Distance.t * Units.Time.t
# let warm_up : leg = Units.(Distance.of_km 1.2, Time.of_minutes 8.);;
val warm_up : leg = (1200., 480.)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 local_open_session.py
Inside SMap.( ... ) the names empty, add and bindings needed no prefix, and the pipeline gave
[("ink", 2); ("pen", 3)], in key order. let open SMap in built the same map as stock, and the tuple
SMap.(find "ink" stock, mem "nib" stock, cardinal stock) gave (2, false, 2). Outside both forms, cardinal stock
was Unbound value cardinal: each open ended with its expression. A top-level open SMap lasts to the end of the file,
or here of the session, so cardinal restock then worked and gave 3. When the expression already has brackets, the
manual lets the parentheses go: SMap.[ ... ], SMap.[| ... |] and SMap.{ ... } open SMap inside a list, an array
or a record.
Local opens in types: OCaml 5.2
Units.(Distance.t * Time.t) opens Units inside a type. The form was added in OCaml 5.2 (Changes, #12044), so code
that uses it needs 5.2 or later; 5.5.1 accepted it and printed the type back with full paths,
Units.Distance.t * Units.Time.t.
The last phrase used the same module both ways: the type leg from a local open in a type, and its value from a local
open in an expression, Units.(Distance.of_km 1.2, Time.of_minutes 8.), which gave (1200., 480.). A local open keeps
the short names next to the code that needs them, and its end shows the reader where they stop.
Checkpoint
Check: Nested modules, paths and aliases · Local opens in expressions and types
include copies a module’s contents, and open does not
extend.ml defines two modules that look alike. Its List starts with include Stdlib.List and adds sum; its Short
starts with open Stdlib.List and defines the same sum:
(* extend.ml: the module Extend. Its List is the standard List plus sum;
its Short opens the standard List and defines the same sum. *)
module List = struct
include Stdlib.List
let sum l = fold_left ( + ) 0 l
end
module Short = struct
open Stdlib.List
let sum l = fold_left ( + ) 0 l
end The session counts what each module exports and calls both:
from session import files, run, toplevel, write
# extend.ml (examples/extend.ml) is the module Extend. Its List includes the standard List and adds sum; its Short
# opens the standard List and defines the same sum. ocamlc -i shows what each one exports: grep counts the values of
# the new List, then of a file that only includes the standard List, and finds both sums. The toplevel then calls
# both modules.
files("extend.ml")
write("only_list.ml", "include Stdlib.List\n")
run("""
ocamlc -c extend.ml
ocamlc -i extend.ml | grep -c '^ val'
cat only_list.ml
ocamlc -i only_list.ml | grep -c '^val'
ocamlc -i extend.ml | grep 'sum'
""")
toplevel("""
Extend.List.sum [72; 85; 64];;
Extend.List.length [72; 85; 64];;
Extend.Short.sum [72; 85; 64];;
Extend.Short.length [72; 85; 64];;
let open Extend in List.sum [1; 2; 3] + List.length [4; 5];;
""", args=("extend.cmo",)) Output
$ ocamlc -c extend.ml
$ ocamlc -i extend.ml | grep -c '^ val'
73
$ cat only_list.ml
include Stdlib.List
$ ocamlc -i only_list.ml | grep -c '^val'
72
$ ocamlc -i extend.ml | grep 'sum'
val sum : int list -> int
module Short : sig val sum : int list -> int end
$ ocaml extend.cmo
OCaml version 5.5.1
Enter #help;; for help.
# Extend.List.sum [72; 85; 64];;
- : int = 221
# Extend.List.length [72; 85; 64];;
- : int = 3
# Extend.Short.sum [72; 85; 64];;
- : int = 221
# Extend.Short.length [72; 85; 64];;
Line 1, characters 0-19:
1 | Extend.Short.length [72; 85; 64];;
^^^^^^^^^^^^^^^^^^^
Error: Unbound value Extend.Short.length
# let open Extend in List.sum [1; 2; 3] + List.length [4; 5];;
- : int = 8
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 include_session.py
Both sums gave 221 for [72; 85; 64], because inside each module the names of List are short. The difference is what
each module exports. grep found 73 values in the interface ocamlc -i printed for Extend.List: the 72 that the
one-line file only_list.ml, include Stdlib.List, exports, plus sum. Extend.Short exports sum alone, and Extend.Short.length was
Unbound value Extend.Short.length. The manual draws the line in one sentence: open only “provides short names” for
what the opened module contains and defines nothing in the module being written, while include adds definitions.
That makes module List = struct include Stdlib.List ... end the usual way to extend a standard module. Inside
let open Extend in, List means the extended one, so List.sum [1; 2; 3] + List.length [4; 5] used a new function and
a standard one and gave 8. Writing Stdlib.List names the standard module plainly, whatever List means at that point
of the file.
The five ways to bring a module closer differ in two things, how far the short names reach and whether the module you are writing exports anything new. Each row comes from this lesson’s sessions:
| Form | Short names reach | Adds to what your module exports | In this lesson |
|---|---|---|---|
open SMap |
to the end of the file | nothing | cardinal restock gave 3 |
let open SMap in e |
e only |
nothing | built stock |
SMap.( e ) |
e only, and types since 5.2 |
nothing | Units.(Distance.t * Time.t) |
include Stdlib.List |
to the end of the structure | every item of List | Extend.List: 73 values |
module D = Units.Distance |
nowhere: a second name | the name D |
D.of_km 1.2 |
A rule of thumb
Write the qualified name when a module is used a few times, a local open when one expression uses it a lot, and a top-level open only for modules made to be opened, such as Printf; OCaml.org’s modules tutorial advises against opening List. Reach for include only when your module should export what it includes.
An open can hide a name you meant to use
An open puts every name of a module in front of the names already in scope, and some of those names may be ones you
meant to use. The session below starts three toplevels: one that opens SMap at the top level, one with the default
warnings, and one started with -w +44+45:
from session import files, run, toplevel, write
# Three toplevels. With SMap opened at the top level, compare is SMap.compare. Without warning options, an open that
# hides a name says nothing. With -w +44+45 the toplevel names the open that hides a value or a module (warning 44) or
# a constructor (warning 45); a qualified name opens nothing, and open! marks a hiding that is meant.
write("defs.ml", "module SMap = Map.Make (String)\n")
toplevel("""
open SMap let restock = add "nib" 10 empty;;
compare 3 4;;
Stdlib.compare 3 4;;
""", args=("-init", "defs.ml"))
toplevel("""
let tolerance = 0.01;;
module Physics = struct let g = 9.81 let tolerance = 1e-9 end;;
let close a b = let open Physics in Float.abs (a -. b) < tolerance;;
close 1.0 1.005;;
""")
files("extend.ml")
run("ocamlc -c extend.ml")
toplevel("""
let tolerance = 0.01;;
module Physics = struct let g = 9.81 let tolerance = 1e-9 end;;
let close a b = let open Physics in Float.abs (a -. b) < tolerance;;
let close a b = Float.abs (a -. b) < tolerance;;
close 1.0 1.005;;
type light = Red | Amber | Green;;
module Paint = struct type t = Red | Blue end;;
let c = Paint.(Red);;
let c = Paint.Red;;
let total = let open Extend in List.sum [72; 85; 64];;
let total = let open! Extend in List.sum [72; 85; 64];;
""", args=("-w", "+44+45", "extend.cmo")) Output
$ ocaml -init defs.ml
OCaml version 5.5.1
Enter #help;; for help.
# open SMap let restock = add "nib" 10 empty;;
val restock : int SMap.t = <abstr>
# compare 3 4;;
Line 1, characters 8-9:
1 | compare 3 4;;
^
Error: The constant 3 has type int but an expression was expected of type
'a -> 'a -> int
# Stdlib.compare 3 4;;
- : int = -1
$ ocaml
OCaml version 5.5.1
Enter #help;; for help.
# let tolerance = 0.01;;
val tolerance : float = 0.01
# module Physics = struct let g = 9.81 let tolerance = 1e-9 end;;
module Physics : sig val g : float val tolerance : float end
# let close a b = let open Physics in Float.abs (a -. b) < tolerance;;
val close : float -> float -> bool = <fun>
# close 1.0 1.005;;
- : bool = false
$ ocamlc -c extend.ml
$ ocaml -w +44+45 extend.cmo
OCaml version 5.5.1
Enter #help;; for help.
# let tolerance = 0.01;;
val tolerance : float = 0.01
# module Physics = struct let g = 9.81 let tolerance = 1e-9 end;;
module Physics : sig val g : float val tolerance : float end
# let close a b = let open Physics in Float.abs (a -. b) < tolerance;;
Line 1, characters 20-32:
1 | let close a b = let open Physics in Float.abs (a -. b) < tolerance;;
^^^^^^^^^^^^
Warning 44 [open-shadow-identifier]: this open statement shadows the
value identifier tolerance (which is later used)
val close : float -> float -> bool = <fun>
# let close a b = Float.abs (a -. b) < tolerance;;
val close : float -> float -> bool = <fun>
# close 1.0 1.005;;
- : bool = true
# type light = Red | Amber | Green;;
type light = Red | Amber | Green
# module Paint = struct type t = Red | Blue end;;
module Paint : sig type t = Red | Blue end
# let c = Paint.(Red);;
Line 1, characters 8-13:
1 | let c = Paint.(Red);;
^^^^^
Warning 45 [open-shadow-label-constructor]: this open statement shadows the
constructor Red (which is later used)
val c : Paint.t = Paint.Red
# let c = Paint.Red;;
val c : Paint.t = Paint.Red
# let total = let open Extend in List.sum [72; 85; 64];;
Line 1, characters 16-27:
1 | let total = let open Extend in List.sum [72; 85; 64];;
^^^^^^^^^^^
Warning 44 [open-shadow-identifier]: this open statement shadows the
module identifier List (which is later used)
val total : int = 221
# let total = let open! Extend in List.sum [72; 85; 64];;
val total : int = 221
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shadow_session.py
In the first toplevel, open SMap put SMap’s compare, which compares two maps, in front of Stdlib’s, so
compare 3 4 was a type error: the constant 3 was expected to be a function of type 'a -> 'a -> int.
Stdlib.compare 3 4 still gave -1. In the second, the program wants a tolerance of 0.01, defined at its top level,
but close opens Physics to shorten a formula, and Physics has a tolerance of its own. Physics.tolerance
shadowing: Hiding an earlier binding by binding the same name again. Code written before the new binding still refers to the old one. More in “Expressions, let bindings, if and scope” the 0.01, so close 1.0 1.005 gave false, and nothing said why. Step through
the lookup that went wrong:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Two scopes to start with. The program defines tolerance = 0.01 at its top level, and every file starts with Stdlib opened. A name used inside close is looked up in the nearest scope first, then outwards.
- The open adds a scope. let open Physics in puts every name of Physics in a scope between the body of close and the rest of the file. Physics has a tolerance of its own, 1e-9.
- The first match wins. The lookup stops at the first scope that has the name. Physics.tolerance is found, so the 0.01 one scope further out is never read.
- A wrong answer, in silence. close 1.0 1.005 compared a difference of 0.005 with 1e-9 and gave false. The toplevel printed no warning at all: warnings 44 and 45 are off unless they are switched on.
- Warning 44 names the open. Started with -w +44+45, the toplevel flagged the open: this open statement shadows the value identifier tolerance (which is later used). It still accepted close, which reads 1e-9 as before.
- The fix reads 0.01. close written without the open has no Physics scope in its way, so it reads the 0.01 of the file: close 1.0 1.005 gave true, with no warning. Physics.g written in full still reaches the other module.
The compiler stays quiet because warnings 44 and 45 are off by default: the default warning setting the manual gives
includes -44-45. Warning 44 reports an open that shadows a value, a type or a module that is later used; warning 45 does the
same for a label or a constructor. The third toplevel named each case: tolerance, the constructor Red that
Paint.(Red) took from Paint instead of the type light, and the module List that let open Extend in replaced. The
qualified forms drew no warning: close without the open gave true, and Paint.Red printed nothing. open! is the
third answer. The manual calls the hiding it marks “intentional”: the ! tells the compiler not to warn, so
let open! Extend in gave 221 in silence.
An open at the top of a file
Wrong: open SMap as the first line, then compare 3 4 or List.sort compare xs further down, where compare is now
SMap.compare. Right: SMap.compare or Stdlib.compare written out, or a local open around only the expressions that
work on maps.
In a dune project the same opens build without a word. The compile command that the wrapped session printed shows the
flags of dune’s default dev profile: -warn-error +a, which turns every warning that is on into an error, but no -w
option, so 44 and 45 stay off. This project’s bin/main.ml opens Physics by mistake in close and Extend on purpose
with open!:
(* main.ml: one open hides a name by accident, the other on purpose. *)
let tolerance = 0.01
module Physics = struct
let g = 9.81
let tolerance = 1e-9
end
(* Meant to compare with the 0.01 above. *)
let close a b =
let open Physics in
Float.abs (a -. b) < tolerance
(* Extend.List is the standard List plus sum, meant to replace it here. *)
let total marks =
let open! Extend in
List.sum marks
let () =
Printf.printf "close %b, total %d, g %g\n" (close 1.0 1.005) (total [ 72; 85; 64 ]) Physics.g from session import files, run
# The same two opens in a dune project (examples/shadow/): bin/main.ml opens Physics inside close by accident and
# opens Extend on purpose with open!. The dev profile builds it without a word. With -w +44+45 added to the
# executable's flags, the build stops at the accidental open only. Then bin/main.ml is replaced by main_fixed.ml,
# whose close opens nothing, and the build passes.
files(
"shadow/dune-project",
"shadow/bin/dune",
"shadow/bin/main.ml",
"extend.ml=shadow/bin/extend.ml",
"shadow/main_fixed.ml",
)
run("""
cd shadow
dune build
dune exec ./bin/main.exe
printf '(executable\\n (name main)\\n (flags (:standard -w +44+45)))\\n' > bin/dune
cat bin/dune
!dune build
cp main_fixed.ml bin/main.ml
grep -n 'open' bin/main.ml
dune build
dune exec ./bin/main.exe
""") Output
$ cd shadow
$ dune build
$ dune exec ./bin/main.exe
close false, total 221, g 9.81
$ printf '(executable\n (name main)\n (flags (:standard -w +44+45)))\n' > bin/dune
$ cat bin/dune
(executable
(name main)
(flags (:standard -w +44+45)))
$ dune build
File "bin/main.ml", line 11, characters 6-18:
11 | let open Physics in
^^^^^^^^^^^^
Error (warning 44 [open-shadow-identifier]): this open statement shadows the
value identifier tolerance (which is later used)
[exit status 1]
$ cp main_fixed.ml bin/main.ml
$ grep -n 'open' bin/main.ml
14: let open! Extend in
$ dune build
$ dune exec ./bin/main.exe
close true, total 221, g 9.81
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shadow_dune_session.py
The default build printed close false, total 221, g 9.81. With (flags (:standard -w +44+45)) in bin/dune, the dev
profile’s -warn-error +a made the warning an error: the build stopped at line 11, let open Physics in, with
Error (warning 44 [open-shadow-identifier]), and said nothing about the let open! Extend in on line 16. With close
rewritten without the open, from main_fixed.ml, the build passed and printed close true.
Key idea
An open puts every name of a module in front of yours; switch on warnings 44 and 45 to see what it hides, then write the qualified name, or open! where the hiding is the point.
Checkpoint
Check: include and open compared · Shadowing by open, warnings 44 and 45
Practise: files, paths and opens
The questions rename files, add functions to Extend.List and move opens around in the lesson’s own code. Decide each answer from the rule it tests before you check it:
Practice that adapts to you
Practise this lesson
18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): how do open and include differ, and when do you reach for each?
open M lets the following code use M’s names without the M. prefix, to the end of the structure, or for one
expression as a local open, M.( ... ) or let open M in. It defines nothing, so a module that opens List exports
none of List’s functions. include M copies every definition of M into the module being written, so they become part
of what it exports: a module List that includes Stdlib.List and adds sum exports all of List’s values plus sum. Use
open, ideally a local open, to shorten names in code you write; use include to extend or re-export a module. Both can
hide names already in scope, so for a name used only a few times the qualified form is the safest.
The two questions below go further: one about two files of a library that each need the other, one about two libraries that each bring their own util.ml. For each, decide which output you would read first before you look at its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
In a dune library, order.ml calls Customer.name to label an order and customer.ml calls Order.total to add up what a customer spent. dune stops with "Error: Dependency cycle between" the two modules. Why can OCaml not build these files as they are, and how would you restructure them?
-
Mid to senior
Two internal libraries, maps and billing, were ported to dune with (wrapped false), and each has a util.ml. A program that links both fails with "make inconsistent assumptions over interface Util". What is wrong, and how would you fix it without breaking callers that still write Route and Invoice with no prefix?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which module does the file my_shapes.ml define, and why does my-shapes.ml fail?
My_shapes: every .ml file is a module named after the file with its first letter capitalised. my-shapes.ml compiles with warning 24, but no other file can name the module.
-
Why can shape.ml stop with Error: Unbound module Vector?
A file compiles only once the modules it names have compiled interfaces, so compiling shape.ml before vector.ml fails.
-
What are a dune library's modules called inside the library and outside it?
Outside, Geometry.Vector and Geometry.Shape; inside, plain Vector and Shape, because dune opens the library module it generates. (wrapped false) puts them at the top level.
-
How is a module defined inside a file reached, and how do you shorten its name?
By a path such as Units.Distance.of_km. module D = Units.Distance gives a shorter name that the compiler treats as the same module.
-
How far do open M, let open M in e and M.( e ) reach?
open M to the end of the file; the two local forms stop at the end of one expression. Since OCaml 5.2, M.( … ) also works inside types.
-
How do include and open differ?
include copies a module's definitions into the module being written, so they are exported: Extend.List exports all of List plus sum. open only shortens names and exports nothing.
-
What can a top-level open SMap silently do to a later compare 3 4?
Make it mean SMap.compare. Warnings 44 and 45 report such hiding once switched on, open! marks a hiding you intend, and a qualified name such as Stdlib.compare avoids the question.
-
When is a qualified name, a local open or a top-level open the better choice?
A qualified name when a module is used a few times, a local open when one expression uses it a lot, and a top-level open only for modules made to be opened, such as Printf.
Key takeaways
- Every .ml file is a module named after the file with its first letter capitalised, and a file compiles only once the modules it names have compiled interfaces.
- A dune library wraps its files: outside it they are Geometry.Vector and Geometry.Shape, inside it plain Vector and
Shape, and
(wrapped false)puts them at the top level under their own names. - A module defined inside a file is reached by a path such as
Units.Distance.of_km, andmodule D = Units.Distancegives it a shorter name that the compiler treats as the same module. open M,let open M in eandM.( e )all make M’s names short; the local forms stop at the end of one expression, and since OCaml 5.2M.( ... )also works in types.includecopies a module’s definitions into the module being written, which is how Extend.List exports all of List plus sum, whileopenexports nothing.- An open can hide a name you meant to use without a warning; warnings 44 and 45 report it once switched on,
open!marks a hiding you intend, and a qualified name avoids the question.
Terms in this lesson
- compilation unit
- A source file compiled on its own into a module: vector.ml gives the module Vector, its compiled interface vector.cmi, against which other files are checked, and its compiled code vector.cmo.
- local open
- Opening a module for one expression only, written M.( ... ) or let open M in ..., so the names of M are short inside that expression and need the prefix everywhere else.
- module
- A named collection of definitions (values, types, exceptions and other modules), reached from outside with a dot, as in List.length. Every .ml file is a module named after the file.
- module alias
- A second name for an existing module, written module D = Units.Distance. The compiler treats both names as the same module, so D.t and Units.Distance.t are the same type.
References
- The OCaml Manual: The module system (tutorial) (Inria and the OCaml developers)
- The OCaml Manual: Module expressions (open and include) (Inria and the OCaml developers)
- The OCaml Manual: Compilation units (Inria and the OCaml developers)
- The OCaml Manual: Batch compilation (module names and the list of warnings) (Inria and the OCaml developers)
- The OCaml Manual: Expressions (local opens) (Inria and the OCaml developers)
- The OCaml Manual: Type-level module aliases (Inria and the OCaml developers)
- The OCaml Manual: Overriding in open statements (Inria and the OCaml developers)
- Modules (OCaml.org)
- Libraries With Dune (OCaml.org)
- Dune documentation: library (the wrapped field) (The dune developers)
- OCaml Changes (local module opens in types, 5.2) (The OCaml developers)
Report a problem with this lesson
Kept only in this browser. Your Learn progress