Dive C1.02.2
Shadowing, not mutation
Rebinding a name makes a new binding; the old value is untouched.
If a let binding cannot be reassigned, what is happening when F# code
writes let x = x + 1? Not reassignment. It is a second binding of the name
x, computed from the first, that shadows the earlier one. The earlier
value still exists, unchanged; the new name takes priority for code that comes after. Shadowing and
mutation look the same on the page and are not the same underneath — and a closure that captured the first
binding proves it.
Rebinding a name is a fresh binding
Writing let x = … a second time does not change the first x.
It introduces a new binding of the same name. The right-hand side can read the old binding — it is still in
scope when the new one is being defined — so let x = x + 1 means: take the current
x, add one, and bind the result to a new x.
let x = 1 let x = x + 1 // a new binding x = 2, computed from the old x (1) printfn "%d" x // 2
After these lines, the name x refers to
2. The original binding to 1 was not overwritten; it was used
to compute the new one and then shadowed. This reads like assignment and is not — the difference shows the
moment a closure enters.
- shadowing
- a later
letbinds the same name afresh, hiding the earlier binding. - old binding
- still exists; the right-hand side may read it while computing the new value.
- not mutation
- no value changed in place; a second, distinct binding was created.
An inner scope shadows only within itself
A binding inside a function or block shadows the outer name only for the duration of that scope. Step out, and the outer binding is back, exactly as it was.
let x = 10 let f () = let x = 20 // shadows within f only x f () // 20 x // still 10
Inside f, the name x
is 20; the inner binding wins there. Outside f, the outer
binding is untouched, so x is 10 still. Shadowing is scoped:
it never reaches back out to change a binding in an enclosing scope.
The decisive case: a closure captures the binding in scope
This is where shadowing and mutation part ways for good. A function captures the binding that was in scope when it was defined. Shadow the name afterwards and the function is unmoved — it still refers to the binding it captured, not the newer one.
Step through the four lines below. Watch the binding stack on the left grow, and watch which
x the function getX is tied to.
getX captures the first x · a later shadow does not change it
- let x = 1 binds x to 1
- let getX () = x captures the first x (1)
- let x = 2 a new binding shadows for later code
- getX () returns 1 — the captured x, not the shadow
A closure is tied to the binding, not the name. Shadow the name a hundred times after; the function still returns the value it captured. Mutation would have changed it — shadowing cannot.
looks like assignment
let x = x + 1 reads like overwriting x in place, the way an imperative variable would.
is a new binding
The earlier binding survives; a closure that captured it returns 1, proving nothing was mutated.
Why this matters, and what comes next
Shadowing is useful: it lets a name be refined through a pipeline of steps without inventing
x1, x2, x3, while keeping every
earlier binding intact for anything that already captured it. Because no value changes in place, the
equational reasoning from C1.02.1 still holds — each x refers to a
single, fixed value within its scope.
It also has an edge: a shadow can hide a name you meant to use, and the types need not match across the
shadow, so a typo can compile. That is the cost of the convenience, not a reason to avoid it. When a value
genuinely must change in place — a loop counter, a running total — the tool is mutable,
and that is C1.02.3.
References
Sources
- Microsoft — F# documentation — let bindings, scope, and shadowing.
- The F# language specification — name resolution and the rules that govern shadowing.
- The F# Software Foundation — the language's home and its functional-first model.
Related in this course
- C1.02.3 — Mutability when you need it — the case where a value really does change in place.
- C1.02 — Bindings — the module hub and its three dives.
- Elixir — pure functions — why no action at a distance keeps reasoning local.