Dive C1.02.3
Mutability when you need it
Opt in with mutable; write with <-. Deliberate, and kept local.
Immutable is the default, not a prohibition. When a value genuinely must change in place — a loop
counter, a running total, a buffer handed to a .NET API — F# offers let mutable, and
the <- operator writes a new value to that binding. This dive shows the
mutable form, the reference-cell alternative, the sharp line between mutation and
shadowing, and the guidance: reach for it deliberately and keep it local — the same answer is often one
immutable call away.
let mutable and the <- operator
Adding the mutable keyword to a binding makes it assignable. From then on,
name <- newValue stores a new value in the same binding. The classic use is an
accumulator updated across a loop.
let mutable total = 0 for i in 1..5 do total <- total + i total // val it : int = 15
Here total starts at 0
and the loop body assigns a new value on each pass: 0, 1, 3, 6, 10, 15. The same
binding is updated five times in place. After the loop, total is
15 — the sum of 1 through 5.
- let mutable n = v
- create an assignable binding initialised to
v. - n <- e
- the assignment operator — store the value of
ein the bindingn. - same binding
- mutation changes the value the existing name holds; no new binding is made.
Reference cells: ref, .Value, <-
A reference cell is the other way to hold a value that changes. ref v
creates a cell holding v; you read it with .Value and write
it by assigning to .Value. The cell itself is an ordinary immutable
let binding — what changes is the contents it points at.
let counter = ref 0 counter.Value <- counter.Value + 1 counter.Value // 1
The binding counter never changes — it is
the same cell throughout. counter.Value reads the contents,
counter.Value <- … writes them. A reference cell is a value you can pass around and
mutate through, which is what distinguishes it from a plain mutable binding.
Both forms use <- to write. With mutable you
assign to the name; with a ref cell you assign to its .Value. The
.Value property is the current idiom; older F# code used the
! and := operators for the same reads and writes.
Watch the cell change — then the immutable equivalent
Step the loop one iteration at a time. The cell on the left is the single total
binding; each step assigns it a new value with <-. To the right is the same
result with no mutation at all — List.sum over the list
[1..5].
let mutable total = 0 · total <- total + i over 1..5
- let mutable total = 0 initial value, before the loop
- i = 1 · total <- 0 + 1 = 1 first iteration
- i = 2 · total <- 1 + 2 = 3 second iteration
- i = 3 · total <- 3 + 3 = 6 third iteration
- i = 4 · total <- 6 + 4 = 10 fourth iteration
- i = 5 · total <- 10 + 5 = 15 fifth iteration — done
// the same result, no mutation [1..5] |> List.sum // val it : int = 15
Mutation is a tool, not a default. The loop reaches 15 by updating one
binding five times; [1..5] |> List.sum reaches the same
15 by changing nothing. Prefer the second; use the first where it earns its keep.
mutation
total <- total + i changes the value the same binding holds, again and again.
shadowing
let x = x + 1 makes a new binding and leaves the old value intact — a different thing entirely.
<- is assignment; = is binding
Two operators that read alike and do not coincide. = binds a name or tests equality;
<- assigns to a mutable target. Writing let x = 5 binds;
writing x <- 6 assigns — and assignment only compiles when the target was declared
mutable or is a reference cell. Confusing the two is a common first-week error, and
the compiler's FS0027 (from C1.02.1) is exactly the reminder that the target was not mutable.
The guidance is short. Keep immutability the default. Reach for mutable deliberately
— hot loops, local accumulation, .NET interop — and keep it local, so the mutation cannot be observed from
outside. Where a standard-library function expresses the same computation,
List.sum, List.fold, and the rest say it without any
mutation at all. That choice is the whole of C1.02: a binding holds; a shadow replaces a
name; mutation, used sparingly, changes a value in place.
References
Sources
- Microsoft — F# documentation — mutable bindings, reference cells, and the <- operator.
- FSharp.Core API reference —
ref,List.sum, and the collection functions. - The F# language specification — the rules for mutable values and assignment.
Related in this course
- C1.02.2 — Shadowing — the new-binding that mutation is contrasted against.
- C1.02 — Bindings — the module hub and its three dives.
- Elixir — pure functions — why keeping mutation out of reach keeps functions predictable.