Tutorial for Module 8: a tiny well-typed evaluator
This is the capstone for the OCaml half of the course. We are going to build a small expression language that combines almost everything we have seen in Module 8:
- A GADT for the abstract syntax tree, so that ill-typed programs cannot be constructed.
- A pattern-matching evaluator that uses GADT type refinement to return the right type for each constructor.
- An optional-failure layer, in the option-monad style, that short-circuits when evaluation has a runtime problem (such as division by zero).
- A pretty-printer and an extension exercise (adding
<).
The exercise serves two purposes. First, it is a useful piece of code on its own: a typed mini-interpreter is the foundation of many production OCaml tools (configuration languages, query builders, embedded scripting). Second, it is a working example that ties together monads, GADTs, and pattern matching, the three big OCaml ideas from the back half of the functional-programming half of the course.
By the end of this lecture you should be able to read GADT-typed ASTs comfortably, write evaluators over them, and extend the language with new constructors without confusion.
The typed AST
The first design decision is the AST shape. We want six constructors:
Read the type definition slowly. Each constructor has its own
signature with an explicit result type. Int_lit : int -> int expr means "given an OCaml int, build an expression of type
int expr". The literal 5 becomes Int_lit 5 : int expr and
not Int_lit 5 : 'a expr for some unknown 'a. Each constructor
pins its index.
Add and Mul require both their sub-expressions to have type
int expr. You cannot construct Add (Bool_lit true, Int_lit 5):
the first argument's type does not match what Add expects. The
arithmetic operations also produce int expr, because the result
of adding two integers is an integer.
Eq_int is interesting: it takes two int exprs but produces a
bool expr. This is the constructor that changes type indices.
You compare two integers; the result is a boolean. The type
system tracks the change.
If is the most subtle. Its condition is bool expr (since you
need a boolean to branch on), and the two branches share a
parameter 'a: whatever type the "then" branch has, the "else"
branch must have the same. The result is 'a expr for that
shared type. So If between two int exprs produces an int expr; If between two bool exprs produces a bool expr; If
between an int expr and a bool expr is a type error.
This is the design pattern for typed embedded languages: the compiler turns the OCaml type system into a type checker for the embedded language. We get the embedded language's type safety for free, with the host language's machinery.
The evaluator
Now to evaluate. The evaluator takes an 'a expr and returns
an 'a:
The function reads naturally for a language interpreter, but
notice the depth of compiler-tracked typing. The single function
eval has a type that says "I return whatever type the input
expression is indexed by", and OCaml verifies that every branch
returns the right thing.
For Eq_int (a, b) -> eval a = eval b: the GADT constructor's
signature is int expr * int expr -> bool expr. In this branch,
a (the locally abstract type parameter) is refined to bool.
The recursive calls eval a and eval b both return ints (the
sub-expressions have type int expr, so they evaluate to int).
The = between two ints is a bool. Returning that bool
matches the refined a = bool. Type-checks.
This is real type-system work. Without GADTs you cannot write
this evaluator: an ordinary variant cannot encode that Eq_int
returns bool while Add returns int. With GADTs the encoding
falls out.
Building expressions
Let us try the evaluator on a couple of expressions:
Notice how the construction reads. Mul (Add (Int_lit 1, Int_lit 2), Int_lit 3): an Add between two int literals, then a Mul
between that sum and another int literal. Each piece's type is
fixed by its constructor. The compiler verifies the whole tree at
build time.
The second example uses If and Eq_int. Eq_int (..., ...)
produces a bool expr, which is what If needs as its first
argument. The two Int_lit branches make the If result an int expr. The whole thing has type int expr and evaluates to 100.
What cannot be built
Here is where GADTs pay off. Try the obvious mistakes:
In an interpreter without typed ASTs, you would write the expression, run the interpreter, and get a runtime error: "tried to add a bool to an int, panic." In a GADT-typed interpreter, the compiler refuses to compile the expression in the first place. The class of bug is gone.
For a calculator that may not seem like a big deal. For a serious embedded language with hundreds of expression nodes and many language features, the difference is large: you can refactor the language and trust the type checker to find every place that needs updating, rather than relying on tests to exercise every branch of the interpreter.
Adding a < comparison
Let us extend the language with a less-than operator. The new
constructor has the same shape as Eq_int: it takes two int exprs and produces a bool expr. Only two lines are new (marked
new); the rest repeats the running definitions so the cell is a
complete program:
A useful detail: the
exhaustiveness checker from Module 5
still works for GADTs. When you add Less to the type and forget
to add it to eval, the compiler warns about a non-exhaustive
match. This is one of the things you would lose if you encoded the
AST in a dynamically-typed way (tags plus a dictionary, say): the
exhaustiveness check would not apply.
Adding a pretty printer
The reverse direction: turn an expression back into a string. This
is the pretty-printer for the whole language (< included), built
on the definitions from the previous slide. The return type is
always string, regardless of the expression's type index:
The signature type a. a expr -> string is the right one here.
The function is polymorphic in the input (any expression, of any
type), but the output is always a string. The type a. ...
annotation is needed because we are still pattern-matching on a
GADT; OCaml needs permission to refine a per branch. The right-
hand side of each branch happens not to use a, which is fine,
the output type is string regardless.
This is the pattern for any "consume a GADT, produce a fixed type" function: pretty-printers, hashers, size computations, size estimations. The GADT lets you handle every constructor with the right local types; the result type is whatever fits your purpose.
Combining GADTs with the option monad
So far our eval cannot fail: every constructor evaluates
cleanly. Real interpreters have runtime failures (division by
zero, missing variable, stack overflow). The
option monad is the
natural way to add a layer of fallibility:
The two failure-handling mechanisms address different problems.
The GADT guarantees that you do not mix booleans and integers at
the type level: that bug class cannot exist at runtime, because
it cannot exist at compile time. The option monad handles the
genuine runtime failures: you do not know whether the divisor is
zero until you evaluate it, so you handle that case with None
short-circuit semantics.
For a real interpreter you would pick
result over option so that the
error case carries a useful message ("divide by zero at expression
4711"). The shape of the code is identical: let* chains, GADT
pattern matching with type a. ..., runtime checks in the
constructor cases that can fail.
This is the design pattern for a serious typed interpreter: GADTs at the type level to rule out type errors, monads at the value level to handle runtime errors cleanly. Both pay their own way; together they cover the full spectrum.
The "polymorphic less-than" question
The activity for this lecture: we fixed Less to
int expr * int expr. What changes if its arguments are
polymorphic instead?
The deeper lesson: GADT type indices are about what the compiler
knows, and this one is subtle because OCaml's < happens to be
defined at every type. Parameterise too loosely ('a expr
everywhere) and operations either stop compiling (+ genuinely
needs ints) or, worse, silently weaken (< falls back to
structural comparison, ordering booleans and raising
Invalid_argument on function values at runtime). Parameterise
too tightly (one constructor per OCaml type) and you have more
code to write. The right balance is application-specific.
For a calculator, "tight" is fine. For a richer language, you may
need ordering witnesses or other extensions.
A code quiz to put it together:
The starter expr type already has a
Sub : int expr * int expr -> int expr constructor for
subtraction. Complete eval_arith so that the Int_lit, Add,
and Sub cases evaluate correctly.
Show reference solution
Reference solution:
let rec eval_arith : type a. a expr -> a = function
| Int_lit n -> n
| Add (a, b) -> eval_arith a + eval_arith b
| Sub (a, b) -> eval_arith a - eval_arith b
Same pattern as the main evaluator: each case refines a based
on the constructor (always a = int here, since all three
constructors return int expr), and the right-hand side does the
arithmetic.
A check
We pattern-match on a GADT inside eval. Why is the type a. ... annotation usually needed?
- It is required syntax for any function in OCaml.
- It tells the compiler to treat
aas locally abstract so that each branch can refine it differently. - It improves performance.
- It is only needed when the function is recursive.
Why: without the annotation, OCaml tries to find a single
concrete type for a and fails because different branches refine
a to different concrete types (int, bool, etc.). The type a. ... annotation gives the compiler permission to treat each
branch's a independently. This is the standard idiom for
writing functions on GADTs.
Why does combining GADTs with the option monad in eval_safe
make sense?
- The option monad replaces GADTs.
- GADTs rule out compile-time type errors; the option monad handles genuine runtime failures (like division by zero) that the type system cannot prevent.
- Option is required for any GADT function.
- The two are interchangeable.
Why: the two address different problems. The GADT type system prevents bugs of the form "trying to add a bool to an int" by making them un-constructable. The option monad handles failures that depend on runtime values (zero divisor, missing key) and that no static type can detect. They are complementary, not redundant.
Closing the loop: bad1 from the Module 5 tutorial
Cast your mind back to the
Module 5 tutorial interpreter. It had
three failing programs, bad1, bad2, bad3. Two of them
(bad2's unbound variable, bad3's non-bool condition) are
runtime failures: the kind monads handle gracefully. bad1 is
different. Its source was:
This is the long-promised payoff. The Module 5 tutorial had to live
with eval [] bad1 = None at runtime because ordinary variants lump
all
expression shapes into one type. The GADT-typed version indexes
expr by what it produces (int vs bool). With that indexing,
the construction Add (Bool_lit true, _) is ill-typed: the
compiler sees that the first argument's type does not match what
Add wants, and refuses.
The class of bug that motivated the entire option monad
treatment of eval in the Module 5 tutorial is gone. We do not need to
short-circuit on a type mismatch because the type mismatch cannot
arise. We still want the option monad (or the result monad) for
runtime failures like division by zero, missing variables, or
parse errors. But "tried to add a bool to an int" is no longer one
of those runtime failures, because it is impossible to build a
program that would even try.
This is the lesson of Module 8 distilled: monads handle what the type system cannot see; GADTs let the type system see more. The two are complementary, and together they shrink the runtime failure surface of a typed interpreter to the genuine impossibles (divisor zero, missing key) that no static check can prevent.
Aside: variables and let (book only)
This section is in the book, not the slides; skip it on a first
read. The Module 5 AST had two features ours leaves out: a Var
for variables and a Let_in that bound one, carrying an optional
type annotation (ty option). The annotation is the easy part: in
the GADT AST every expression already knows its own type, so there
is nothing left to annotate, and the type the annotation would have
recorded is checked for free.
Variables are the hard part. A Var "x" has whatever type x was
bound to, and to keep that static the compiler must track a
typed environment, a sizeable step up. One clean way to get let
with no named environment at all is higher-order abstract syntax
(HOAS): make the body of the binding an OCaml function, so OCaml's
own variables stand in for the language's.
The body is an ordinary OCaml function, so OCaml's own variables
stand in for the language's, fully typed: Let (Int_lit 10, fun x -> ...) gives x : int expr, and using x where a bool expr is
wanted would not compile.
Let (e, body) -> eval (body e) simply substitutes the expression
e into the body. Because this little language has no side effects,
that is observationally fine, even though e is re-evaluated on each
use of the variable (at worst a little wasted work). A call-by-value
version that ran e exactly once would have to feed its value
back to body, which expects an 'a expr; you could not tell
whether to wrap that value with Int_lit or Bool_lit, so it would
need a generic Val : 'a -> 'a expr constructor. With no effects to
force the issue, substitution is the simpler choice.
Named variables with fully static types instead need the typed environment mentioned above, which is where dependently typed languages (Agda, Idris, F*) and OCaml's own typed-tagless interpreters go next. That is one step past where this course stops.
You finished Module 8
Module 8 is the most "advanced OCaml" part of the course so far. Monads and GADTs are not in everyday OCaml code; they are in the core of libraries, in interpreters, in serious type-safe APIs. You do not need to use them daily, but you need to recognise them when reading other people's code, and you need to be able to reach for them when the problem calls for them.
The functional-programming half of the course
(Modules 1-8) is now complete. We
started from primitive literals and built
up through let bindings,
functions,
recursion,
pattern matching,
algebraic data types,
higher-order functions,
modules,
monads, and
GADTs. Each module fed the next: refs
in M07 gave the imperative ground for the state monad in M08;
variants in M04 set up GADTs in M08; modules in M07 are the
packaging you reach for whenever you grow a real codebase.
The remaining modules turn from the language itself to building real systems with it. The typed-first thinking you built in Modules 1-8 is the foundation they rest on.
What you carry forward from here is not the specific syntax of
let* or the exact shape of a GADT constructor, but a taste for
typed-first thinking: pick the data shape, then write functions on
it; lean on the compiler to find your missing cases; keep mutation
in a small corner and prefer values everywhere else. Those habits
travel: to systems code, to web back-ends, to whatever you build
next.
Reading
- Real World OCaml, Building a typed AST: https://dev.realworldocaml.org/gadts.html
Sources
This lecture's prose, worked examples, and quizzes are original to
this course. Materials referenced during preparation are listed in
the Reading section above; Cornell CS3110 and Real World OCaml
are CC BY-NC-ND-licensed and have not been derivatively reused.
See LICENSES.md
at the repository root for the full source posture.