OCaml Module 2 – Expressions, values and functions
Expressions, let bindings, if and scope
See why every piece of OCaml is an expression with a type, bind and shadow names with let, use if as an expression, and control side effects with ; and unit.
What you will learn
- Explain that OCaml programs are built from expressions that each have a type and a value
- Write top-level let definitions and local let ... in expressions, and predict shadowing
- Use if ... then ... else as an expression whose branches have the same type
- Order side effects with ; and unit, and force an evaluation order with let
Before you start
What you’ll learn · Before you start
What you will learn
- Explain that OCaml programs are built from expressions that each have a type and a value
- Write top-level let definitions and local let ... in expressions, and predict shadowing
- Use if ... then ... else as an expression whose branches have the same type
- Order side effects with ; and unit, and force an evaluation order with let
Before you start
On this page
In OCaml, let x = x + 1 does not change x. A function defined before that line still returns the old value, 10,
while x itself now gives 11, because the second let made a new binding instead of updating the first one.
You will see why every piece of an OCaml program is an expression with a type and a value, bind and shadow names with
let, use if as an expression whose branches agree, and control side effects with ;, unit and let … in.
Every piece of OCaml is an expression with a type and a value
6 * 7, "pass", a function call and even an if are all expressions: each one is computed to a value, and the
compiler knows the value’s type before the program runs. The toplevel, ocaml, shows both for every phrase it
reads. A phrase ends with ;;, and the answer starts with val name : for a new binding or with - : for a value
that was only computed, followed by the type and the value.
A let binding: A name given to a value with let. The binding never changes; a later let with the same name makes a new binding instead. gives a value a name. The OCaml manual’s tour of the core language
introduces values this way, and a binding never changes afterwards: there is no assignment to a name. A later
let x with the same name makes a new binding that hides the first one, which is called
shadowing: Hiding an earlier binding by binding the same name again. Code written before the new binding still refers to the old one.. The session below binds x, defines a function that reads it, and then binds
x again:
from session import toplevel
# A top-level let binds a name for the rest of the session; a second let x shadows the first instead of changing
# it, and let ... in binds a name for one expression only.
toplevel("""
let x = 10;;
let get_x () = x;;
let x = x + 1;;
x;;
get_x ();;
let total = let a = 2 in let b = a * 3 in a + b;;
let inner = let x = 100 in x + 1;;
x;;
""") Output
$ ocaml OCaml version 5.5.1 Enter #help;; for help. # let x = 10;; val x : int = 10 # let get_x () = x;; val get_x : unit -> int = <fun> # let x = x + 1;; val x : int = 11 # x;; - : int = 11 # get_x ();; - : int = 10 # let total = let a = 2 in let b = a * 3 in a + b;; val total : int = 8 # let inner = let x = 100 in x + 1;; val inner : int = 101 # x;; - : int = 11
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 let_scope_session.py
x answered 11 but get_x () answered 10: the function refers to the binding that existed when it was defined.
Step through the same phrases and watch which binding each name refers to:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- let x = 10. The first phrase binds the name x to the value 10. A binding never changes; there is no assignment to x anywhere in this session.
- get_x remembers x. get_x is defined while x means the first binding, so its body refers to that binding, which holds 10.
- let x = x + 1. The new phrase computes x + 1 from the first binding, 11, and binds the name x again. The first binding still exists; the name now refers to the new one.
- Two answers, two bindings. x now gives 11, while get_x () still gives 10, because get_x refers to the binding that existed when it was defined.
- A local binding. let x = 100 in x + 1 makes a binding that exists only inside its expression. It gives 101, and the name x outside is untouched.
- Back outside. After the let … in expression, x refers to the second top-level binding again, so the session's last phrase gives 11.
let … in makes a binding for one expression only. let total = let a = 2 in let b = a * 3 in a + b gave 8, and
neither a nor b exists afterwards. let inner = let x = 100 in x + 1 gave 101, and the outer x was still 11.
Comments are written between (* and *) and may nest; a comment that starts with (** is a documentation comment
that the odoc tool reads.
Reading shadowing as assignment
Wrong: “after let x = x + 1, everything that uses x sees 11”. Right: code written before that line keeps the
binding it saw, as get_x () showed. Mutable state in OCaml is explicit, through ref, arrays and records with
mutable fields.
An if expression has one type, so both branches must agree
Because if is an expression, it has a value and therefore one type. if marks >= 40 then "pass" else "retry" has
type string, and a function built around it, grade, has type int -> string. A branch of another type is a type
error, reported where the two types disagree:
from session import toplevel
# if ... then ... else is an expression with one type for both branches; an if without else has type unit. ; binds
# more loosely than if, so a sequence inside a branch needs begin ... end (or brackets).
toplevel("""
let grade marks = if marks >= 40 then "pass" else "retry";;
grade 72;;
if 3 > 2 then 1 else "one";;
if true then print_endline "printed";;
if true then 1;;
let late = false;;
if late then print_string "Late. "; print_endline "Fine is 50.";;
if late then begin print_string "Late. "; print_endline "Fine is 50." end;;
""") Output
$ ocaml
OCaml version 5.5.1
Enter #help;; for help.
# let grade marks = if marks >= 40 then "pass" else "retry";;
val grade : int -> string = <fun>
# grade 72;;
- : string = "pass"
# if 3 > 2 then 1 else "one";;
Line 1, characters 21-26:
1 | if 3 > 2 then 1 else "one";;
^^^^^
Error: This constant has type string but an expression was expected of type
int
# if true then print_endline "printed";;
printed
- : unit = ()
# if true then 1;;
Line 1, characters 13-14:
1 | if true then 1;;
^
Error: The constant 1 has type int but an expression was expected of type
unit
because it is in the result of a conditional with no else branch
# let late = false;;
val late : bool = false
# if late then print_string "Late. "; print_endline "Fine is 50.";;
Fine is 50.
- : unit = ()
# if late then begin print_string "Late. "; print_endline "Fine is 50." end;;
- : unit = ()
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 if_session.py
if 3 > 2 then 1 else "one" failed with “This constant has type string but an expression was expected of type int”:
the first branch fixed the type as int, and the second disagreed. An if without else is allowed only when the
then branch has type unit, because the missing branch is treated as (). That is why
if true then print_endline "printed" worked, and if true then 1 failed “because it is in the result of a
conditional with no else branch”.
Key idea
An if is a value like any other: give both branches the same type, and leave out else only when the then branch returns unit.
The same grammar hides a trap for code with effects. ; binds more loosely than if, so
if late then print_string "Late. "; print_endline "Fine is 50." is read as
(if late then print_string "Late. "); print_endline "Fine is 50.". With late set to false, the session still
printed “Fine is 50.”: only the first call belonged to the branch. Wrapping the branch in begin … end, or in brackets,
puts the whole sequence inside it, and then nothing was printed.
A sequence inside a branch without begin ... end
Wrong: if late then print_string "Late. "; print_endline "Fine is 50.", expecting both calls to depend on late.
Right: if late then begin print_string "Late. "; print_endline "Fine is 50." end, so both calls are inside the
branch.
Checkpoint
Check: Expressions, let bindings and shadowing · if as an expression
unit and the semicolon put side effects in order
unit: The type with exactly one value, written (). Functions that act rather than compute, such as print_string, return it. is the type with exactly one value, written (). Functions that act rather than
compute, such as print_string, return it. Two expressions joined by ; run in order: the first for its effect, and
the second gives the value of the whole. The toplevel shows both, and what happens when a value is thrown away:
from session import toplevel
# unit has one value, (). In e1; e2, e1 runs for its effect and e2 is the result; a value thrown away in e1 gets
# warning 10.
toplevel("""
();;
print_string "first, "; print_string "second"; 42;;
let ignored = 1 + 1; "kept";;
ignore (1 + 1); "kept";;
""") Output
$ ocaml
OCaml version 5.5.1
Enter #help;; for help.
# ();;
- : unit = ()
# print_string "first, "; print_string "second"; 42;;
first, second- : int = 42
# let ignored = 1 + 1; "kept";;
Line 1, characters 14-19:
1 | let ignored = 1 + 1; "kept";;
^^^^^
Warning 10 [non-unit-statement]: this expression should have type unit.
val ignored : string = "kept"
# ignore (1 + 1); "kept";;
- : string = "kept"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sequence_session.py
print_string "first, "; print_string "second"; 42 printed both strings and gave 42. 1 + 1; "kept" computed a value
nobody used, so the toplevel answered with warning 10, non-unit-statement. ignore (1 + 1) says that the value is
not wanted, and the warning goes. In the toplevel the warning is advice; in a dune project it stops the build:
from session import run, write
# The same thrown-away value in a dune project: dune's default dev profile turns warnings into errors, so the build
# stops; ignore makes the intent explicit and the build passes.
write("dune-project", "(lang dune 3.24)\n")
write("bin/dune", "(executable (name main))\n")
write("bin/main.ml", 'let () =\n 1 + 1;\n print_endline "built"\n')
run("""
cat bin/main.ml
!dune build 2>&1
sed -i.orig 's/ 1 + 1;/ ignore (1 + 1);/' bin/main.ml
dune build 2>&1 && dune exec ./bin/main.exe
""") Output
$ cat bin/main.ml
let () =
1 + 1;
print_endline "built"
$ dune build 2>&1
File "bin/main.ml", line 2, characters 2-7:
2 | 1 + 1;
^^^^^
Error (warning 10 [non-unit-statement]): this expression should have type unit.
[exit status 1]
$ sed -i.orig 's/ 1 + 1;/ ignore (1 + 1);/' bin/main.ml
$ dune build 2>&1 && dune exec ./bin/main.exe
built
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dune_strict_session.py
Dune’s default development profile makes warnings errors, so dune build stopped with “Error (warning 10
[non-unit-statement])”, and passed once ignore was added. Dune’s documentation of its OCaml flags describes these
defaults per profile.
Evaluation order is unspecified, so a chain of lets fixes it
The manual’s chapter on expressions leaves the order in which the parts of an expression are evaluated unspecified: for the components of a tuple, the arguments of a function and the elements of a list literal. The program below prints a name as each part is evaluated, compiled once to native code and once to bytecode:
from session import files, run
# The same program compiled to native code with ocamlopt and to bytecode with ocamlc, then run.
files("eval_order.ml")
run("""
ocamlopt -o order_native eval_order.ml
./order_native
ocamlc -o order_bytecode eval_order.ml
./order_bytecode
""") Output
$ ocamlopt -o order_native eval_order.ml $ ./order_native tuple: c b a arguments: z y x list: 3 2 1 let ... in: a b c $ ocamlc -o order_bytecode eval_order.ml $ ./order_bytecode tuple: c b a arguments: z y x list: 3 2 1 let ... in: a b c
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 eval_order_session.py
Both compilers of OCaml 5.5.1 evaluated from right to left: c b a for the tuple, z y x for the arguments and
3 2 1 for the list. A program must not depend on that, because the manual promises nothing. The last line shows the
fix: a chain of let … in evaluates its bindings in the order written, so a b c printed in order.
Name the effects you care about
When two calls both print, write to a file or read input, bind each result with its own let … in in the order you
need, instead of writing both calls inside one tuple or one argument list.
Checkpoint
Check: unit and sequencing · Evaluation order
Practise: values, bindings and order
The questions show phrases like the ones in the sessions and ask for the type, the value or the error that follows. Answer from the rules, then check against the sessions:
Practice that adapts to you
Practise this lesson
10 questions about 4 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): what does shadowing mean in OCaml?
A let binding gives a name to a value, and the binding never changes. Writing let x = 11 after let x = 10 does
not update the first binding: it makes a second binding with the same name, which hides the first one from code that
comes later. Code written earlier, such as a function that read x, still refers to the first binding and still sees
10. A let x = … in expression shadows only inside its own expression. Shadowing is safe precisely because nothing is
mutated; when a program really needs a value that changes, OCaml makes that explicit with a ref or a mutable field.
Two harder questions follow, about a function whose output depends on argument order and about a build that fails on a warning. Decide your answer, then open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A helper is called as log (read_user ()) (read_amount ()), and both functions print a prompt. In the native build the amount prompt appears first. Why, and how would you make the order dependable on every compiler?
-
Fresher to mid
A line such as save record; flush () ran in the toplevel with only a warning, but dune build stops on it with Error (warning 10 [non-unit-statement]). save returns the number of bytes written. What does the error mean, and how would you fix it?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is every piece of an OCaml program, as the toplevel shows it?
An expression with a type and a value; the toplevel prints both for each phrase you enter.
-
A function reads x after let x = 10; later comes let x = x + 1. What does the function return?
Still 10. The second let makes a new binding that hides the first one from later code only; code written earlier keeps the binding it saw.
-
If a let binding never changes, how does OCaml code change state?
Explicitly: through ref cells, arrays and records with mutable fields.
-
Why must both branches of an if have the same type?
if … then … else is an expression with a single type. An if without else is allowed only when its then branch has type unit.
-
How do you make two calls depend on one if condition?
Wrap them in begin … end: if late then begin print_string "Late. "; print_endline "Fine is 50." end. Without it only the first call is inside the branch.
-
In what order are tuple components, function arguments and list elements evaluated?
The order is unspecified. When two effects must happen in a given order, bind each result with its own let … in, in that order; a sequence a; b also runs its expressions in order.
Key takeaways
- Every piece of an OCaml program is an expression with a type and a value, and the toplevel shows both for each phrase.
- A
letbinding never changes; a laterletwith the same name shadows it, and code written earlier keeps the binding it saw. if … then … elseis an expression whose branches must have one type, and anifwithoutelsemust have typeunit.;runs expressions in order for their effects, while the order of tuple components, arguments and list elements is unspecified, so a chain oflet … infixes the order when it matters.
Terms in this lesson
- let binding
- A name given to a value with let. The binding never changes; a later let with the same name makes a new binding instead.
- shadowing
- Hiding an earlier binding by binding the same name again. Code written before the new binding still refers to the old one.
- unit
- The type with exactly one value, written (). Functions that act rather than compute, such as print_string, return it.
References
- The OCaml Manual: The core language (Inria and the OCaml developers)
- The OCaml Manual: Expressions (Inria and the OCaml developers)
- Values and Functions (OCaml.org)
- OCaml flags (dune documentation) (Dune developers)
Report a problem with this lesson
Kept only in this browser. Your Learn progress