Modules: Rooms for Your Code

Your snake left Phase 2 as one glorious pile of functions. Time to give every idea its own room — with a door that you design.

You will be able to…

One pile of functions

Open your snake project from lesson 2.5 and scroll. Records, variants, step, grow, contains, draw_world, key handling, score helpers — thirty-odd definitions standing shoulder to shoulder in one namespace. It works. It is also getting dangerous: every function can see every other function, move-the-snake sits one shelf away from the move-the-menu-cursor you'll want next month, and nothing tells you which helpers are load-bearing and which are private scaffolding.

Programmers solve this with architecture, and OCaml's unit of architecture is the module: a named room for definitions that belong together. Here's one:

Everything between struct and end is ordinary OCaml — let bindings, type definitions, even other modules. From outside, you reach in with the dot: Potion.price, Potion.describe. Two rooms can now happily contain two different move functions; the dot keeps them apart.

Watch what the toplevel answers when you define a module:

Read the answer carefully: after the : comes the module's type — a list of what's inside and the type of each thing. That list is called a signature, and it exists whether or not you write it, exactly like expression types in lesson 1.1. Our next move is to take control of it.

Signatures: designing the door

A signature is the door of the room: it lists exactly what may pass through. You write one with module type, then seal a module with it using a colon — the same colon you use for (x : int), promoted to whole rooms:

heal_amount still exists — describe uses it — but it is no longer visible from outside, because the signature doesn't list it. Uncomment the leak line and the compiler says Unbound value Potion.heal_amount: as far as the rest of the program is concerned, there's a wall where that window used to be. Hiding the scaffolding means you can rebuild it whenever you like, and nobody outside will ever notice.

Abstract types: the crown jewel

So far we hid values. The trick that changes how you design software is hiding a type's definition. In a signature, write type t — full stop, no = — and the world outside may hold a t, pass a t around, store a t in a record… but never look inside one, never forge one, never do anything the module didn't explicitly allow. Break it on purpose — run this and read the error like a detective:

This expression has type Counter.t but an expression was expected of type int. Inside the room, t is int — incr happily computes c + 1. Outside, Counter.t and int are different worlds. The payoff is triple:

Implement Counter so it satisfies the given COUNTER signature: make () starts a counter at zero, value reads it, and incr returns a new counter, one higher — Phase 2 habits apply: no mutation, the old counter must be left exactly as it was.

Inside the room, t really is an int — nobody outside will ever know.
incr c builds a new value: c + 1. value c just hands the int back: c.

Design the door first

Professionals often run this backwards: write the signature first, argue about it, and only then implement. The signature is the design; the struct is mere labour. Try that workflow on a game inventory. One deliberate spec decision: the bag is a shelf, not a set — adding "torch" twice means you own two torches, and size counts both.

Implement Inventory to match INVENTORY: empty is a fresh bag, add returns a new bag with one more item (duplicates allowed!), has tells whether at least one such item is inside, and size counts everything, duplicates included. Keep the representation hidden — the tests only ever use the interface.

The shelf is already declared as a string list, so empty is [].
add puts the item in front with :: — duplicates land on the shelf naturally.
The stdlib does the rest: List.mem finds, List.length counts.

Notice what the tests couldn't do: peek at your list. If you later store the bag as a sorted tree or a count-per-item table, every test — and every caller — keeps working. That's the abstraction dividend, and you'll cash it in lesson 3.2.

Sealing an open room

Last drill: you'll often meet a module that happens to match a signature but was never sealed — a room with the door propped open. Close it.

The Gold purse below works, but it's unsealed: any code can see that t is int and mint fake money with +. Two jobs, vault-keeper: (1) seal it with : GOLD on the module line; (2) extend it — add spend : int -> t -> t to both the signature and the module (spending can never drop the purse below zero — use max). Then prove the seal: add let cheat = Gold.zero + 1, press Check, enjoy the type error, and delete the line again.

Sealing is one small edit on the module line: module Gold : GOLD = struct … end.
spend needs two lines: val spend : int -> t -> t in the signature, and a definition in the struct.
Never-negative: let spend n g = max 0 (g - n).

Fair warning about that Check: tests can only call what the signature exposes, so they prove the purse still works — the seal itself is between you and the compiler. That's exactly why the poke matters. And here's how a sealed value looks from the toplevel:

<abstr> is the toplevel being honest: “there is a value here, and you don't get to see inside.” Remember the look of it — the standard library will show you the same face next lesson.

Deeper: files are modules too

In your localhost projects, every .ml file is automatically a module — world.ml becomes World, no module keyword needed. And the signature? A world.mli file sitting next to it, containing just the sig part. Lesson 3.4 turns this into a full architecture: one room per file, one door per room, and a coach inspecting every door.

Checkpoint quiz

Loot collected

Next: the standard library takes these ideas industrial — functors, machines that build whole modules to order.