Beginner

Variables and Types in OCaml

Learn about let bindings, type inference, algebraic data types, and the option type in OCaml with practical Docker-ready examples

In most languages, variables are mutable containers that hold values. OCaml takes a different approach. With its roots in the ML family of functional languages, OCaml uses let bindings — names bound to values that are immutable by default. Combined with a powerful type inference engine, this means you rarely write type annotations while still getting full compile-time type safety.

OCaml’s type system is static, strong, and inferred. The compiler determines types automatically through Hindley-Milner type inference, catching type errors before your program ever runs. There are no implicit type conversions — if you want to add an integer to a float, you must convert explicitly. This strictness might feel rigid at first, but it eliminates entire classes of bugs.

In this tutorial you’ll learn how let bindings work, explore OCaml’s built-in types, see how the type system enforces correctness, and discover algebraic data types and the option type — OCaml’s elegant replacement for null.

Let Bindings and Basic Types

OCaml’s primitive types include int, float, string, char, and bool. You bind names to values with let, and the compiler infers the type from the value.

Create a file named variables.ml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
(* Variables and Types in OCaml *)

(* === Let Bindings: Immutable by Default === *)
let x = 42
let pi = 3.14159
let name = "OCaml"
let letter = 'A'
let is_functional = true

let () = Printf.printf "x = %d\n" x
let () = Printf.printf "pi = %f\n" pi
let () = Printf.printf "name = %s\n" name
let () = Printf.printf "letter = %c\n" letter
let () = Printf.printf "is_functional = %b\n" is_functional

(* === Type Annotations (Optional but Allowed) === *)
let year : int = 1996
let language : string = "OCaml"
let () = Printf.printf "\n%s first appeared in %d\n" language year

(* === No Implicit Type Coercion === *)
let a = 5
let b = 2.5
(* let wrong = a + b  -- Error! Can't mix int and float *)
let result = float_of_int a +. b
let () = Printf.printf "\nfloat_of_int %d +. %g = %g\n" a b result

(* Integer and float operators are separate *)
let int_sum = 10 + 20
let float_sum = 1.5 +. 2.5
let () = Printf.printf "int: 10 + 20 = %d\n" int_sum
let () = Printf.printf "float: 1.5 +. 2.5 = %g\n" float_sum

(* === Tuples === *)
let point = (3, 4)
let (px, py) = point
let () = Printf.printf "\npoint = (%d, %d)\n" px py

let person = ("Ada", 36, true)
let (pname, age, active) = person
let () = Printf.printf "person = (%s, %d, %b)\n" pname age active

(* === Records === *)
type color = { r : int; g : int; b : int }

let red = { r = 255; g = 0; b = 0 }
let () = Printf.printf "\nred = { r=%d; g=%d; b=%d }\n" red.r red.g red.b

(* Functional update: copy with changes *)
let purple = { red with b = 128 }
let () = Printf.printf "purple = { r=%d; g=%d; b=%d }\n" purple.r purple.g purple.b

(* === Option Type: No Null! === *)
let find_even lst =
  List.find_opt (fun x -> x mod 2 = 0) lst

let () =
  match find_even [1; 3; 4; 7] with
  | Some v -> Printf.printf "\nFirst even: %d\n" v
  | None -> Printf.printf "\nNo even number found\n"

let () =
  match find_even [1; 3; 7] with
  | Some v -> Printf.printf "First even: %d\n" v
  | None -> Printf.printf "No even number found\n"

(* === Variant Types (Algebraic Data Types) === *)
type shape =
  | Circle of float
  | Rectangle of float * float
  | Triangle of float * float * float

let area = function
  | Circle r -> Float.pi *. r *. r
  | Rectangle (w, h) -> w *. h
  | Triangle (a, b, c) ->
      let s = (a +. b +. c) /. 2.0 in
      sqrt (s *. (s -. a) *. (s -. b) *. (s -. c))

let shapes = [Circle 5.0; Rectangle (4.0, 6.0); Triangle (3.0, 4.0, 5.0)]

let () = print_string "\nAreas:\n"
let () = List.iter (fun s ->
  let label = match s with
    | Circle _ -> "Circle"
    | Rectangle _ -> "Rectangle"
    | Triangle _ -> "Triangle"
  in
  Printf.printf "  %s: %.2f\n" label (area s)
) shapes

(* === Mutable References (When You Need Them) === *)
let counter = ref 0
let () = counter := !counter + 1
let () = counter := !counter + 1
let () = Printf.printf "\ncounter = %d\n" !counter

This single file demonstrates the full range of OCaml’s type system, from basic bindings through algebraic data types.

Understanding the Code

Immutable Let Bindings

The let keyword creates a binding between a name and a value. These bindings are immutable — once x is bound to 42, it cannot be changed. You can create a new binding with the same name (shadowing), but the original value is unchanged:

1
2
let x = 10
let x = x + 1  (* This shadows the previous x, not mutation *)

Strict Type Separation

OCaml uses different operators for integers and floats: + for int, +. for float. There is no automatic promotion. Conversion functions like float_of_int and int_of_float make conversions explicit. This prevents subtle precision bugs that plague languages with implicit coercion.

Tuples and Records

Tuples group values of different types without defining a named type. Records are like tuples with named fields — they require a type definition but make code self-documenting. The functional update syntax ({ record with field = value }) creates a new record with one or more fields changed, leaving the original untouched.

The Option Type

Instead of null or nil, OCaml uses the option type: a value is either Some v or None. The compiler forces you to handle both cases through pattern matching, making null pointer exceptions impossible.

Variant Types

Variant types (also called algebraic data types or sum types) let you define a type as one of several alternatives. Each variant can carry different data. The function keyword creates an anonymous function that immediately pattern matches on its argument — a concise way to write functions that dispatch on type variants.

Running with Docker

1
2
3
4
5
# Pull the official image
docker pull ocaml/opam:alpine

# Run the variables example
docker run --rm -v $(pwd):/home/opam/app -w /home/opam/app ocaml/opam:alpine ocaml variables.ml

Expected Output

x = 42
pi = 3.141590
name = OCaml
letter = A
is_functional = true

OCaml first appeared in 1996

float_of_int 5 +. 2.5 = 7.5
int: 10 + 20 = 30
float: 1.5 +. 2.5 = 4

point = (3, 4)
person = (Ada, 36, true)

red = { r=255; g=0; b=0 }
purple = { r=255; g=0; b=128 }

First even: 4
No even number found

Areas:
  Circle: 78.54
  Rectangle: 24.00
  Triangle: 6.00

counter = 2

Key Concepts

  • Let bindings are immutable by default — names are bound to values, not assigned to mutable containers. Use ref for the rare cases where you need mutation.
  • Type inference handles almost everything — the compiler deduces types from usage, so explicit annotations are optional and typically used only for documentation or disambiguation.
  • No implicit type coercion — integer and float arithmetic use separate operators (+ vs +.), and conversions like float_of_int must be explicit. This prevents subtle precision bugs.
  • Tuples group heterogeneous values without needing a type definition, while records add named fields for clarity and require a type declaration.
  • The option type replaces null — values are Some v or None, and pattern matching forces you to handle both cases at compile time.
  • Variant types (algebraic data types) let you define types as one of several alternatives, each carrying different data. Pattern matching with exhaustiveness checking ensures you handle every case.
  • Mutable references (ref, !, :=) are available when needed, but idiomatic OCaml favors immutable bindings and functional updates.

Running Today

All examples can be run using Docker:

docker pull ocaml/opam:alpine
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining