Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 5 minutes
  • Examples run with ocaml 5.5.1, opam 2.6.0, dune 3.24.2 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 5 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Top-level bindings, shadowing and let ... in Python · let_scope_session.py
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:

Interactive · step by step Shadowing makes a new binding and leaves the old one alone Step through the session's phrases and watch which binding each name refers to, and which value get_x keeps.
Shadowing makes a new binding and leaves the old one alone, step 6 of 6. Showing x, first binding (10), get_x (returns x, the first), x, second binding (11), x inside let … in (100, then gone). In focus: x, second binding.readsx, first binding10get_xreturns x, the firstx, second binding11x inside let … in100, then gone

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. get_x remembers x. get_x is defined while x means the first binding, so its body refers to that binding, which holds 10.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

if as an expression, and the two errors it can give Python · if_session.py
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

  1. Question 1 of 2 After let n = 5;; let show () = n;; let n = n * 2;; what does show () give?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 5

    The same thing happened with get_x () in the session, which still gave 10 after x became 11.

  2. Question 2 of 2 Which of these phrases type-checks?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: if n > 0 then "positive" else "not positive"

    An if is an expression with one type; without else, that type must be unit.

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:

unit, ; and a thrown-away value Python · sequence_session.py
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:

The same warning in a dune build Python · dune_strict_session.py
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:

Which part runs first, native and bytecode Python · eval_order_session.py
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

  1. Question 1 of 2 What does the phrase print_string "a"; 7 give in the toplevel?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It prints a and has the value 7, of type int

    The session's print_string "first, "; print_string "second"; 42 printed both strings and gave 42.

  2. Question 2 of 2 A program calls log (read_name ()) (read_age ()) and the prompts appear as age first, then name. What should you change?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Bind each with let ... in in the order wanted, then call log with the two names

    let name = read_name () in let age = read_age () in log name age prints the prompts in the order written.

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.

  1. Question 1 of 10 In the toplevel, what does an answer starting with - : int = 43 mean?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: An expression was computed to 43, of type int, without binding a name

    The session's x + 1 answered - : int = 43 in the earlier lesson; let x = 10 answered val x : int = 10.

  2. Question 2 of 10 After let total = let a = 2 in let b = a * 3 in a + b;; what happens when you type a;;?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: An error, because a exists only inside the let ... in expression

    let … in limits a name to one expression; total, bound at the top level, is the only name left.

  3. Question 3 of 10 A counter really must change each time a function is called. How does OCaml express that?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: With a ref, such as let count = ref 0, updated with count := !count + 1

    Shadowing never mutates; a ref is the explicit way to have a value that changes.

  4. Question 4 of 10 When may an if be written without an else branch?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: When the then branch has type unit

    if c then e is short for if c then e else (), so e must be unit.

  5. Question 5 of 10 What type did the toplevel give grade, defined as fun marks -> if marks >= 40 then "pass" else "retry"?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: int -> string

    The session printed val grade : int -> string = <fun>.

  6. Question 6 of 10 How many values does the type unit have?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: One, written ()

    Functions such as print_string return () because they are run for what they do.

  7. Question 7 of 10 In a dune project, 1 + 1; print_endline "built" stopped the build. What fixed it?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: ignore (1 + 1); print_endline "built"

    The session's second dune build passed and printed built.

  8. Question 8 of 10 What does the OCaml manual promise about the order in which the components of a tuple are evaluated?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Nothing; the order is unspecified

    Do not rely on it; use let ... in when the order matters.

  9. Question 9 of 10 In the session, did ocamlopt and ocamlc evaluate the tuple in different orders?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: No, both printed c b a for the tuple and z y x for the arguments

    Agreement today is still not a promise; the let ... in version is the one that is guaranteed.

  10. Question 10 of 10 With let late = false, what does if late then print_string "a"; print_string "b" print?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: b

    ; binds more loosely than if. The session printed Fine is 50. with late false, and nothing once the branch was wrapped in begin ... end.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. If a let binding never changes, how does OCaml code change state?

    Explicitly: through ref cells, arrays and records with mutable fields.

  4. 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.

  5. 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.

  6. 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 let binding never changes; a later let with the same name shadows it, and code written earlier keeps the binding it saw.
  • if … then … else is an expression whose branches must have one type, and an if without else must have type unit.
  • ; runs expressions in order for their effects, while the order of tuple components, arguments and list elements is unspecified, so a chain of let … in fixes 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.