Dive C2.02.2
Immutable by default
let is not var; a record has no setters; a changed value is a copy.
In C#, a var can be reassigned and a class field can be mutated in
place; sharing a reference and then changing it is how a value drifts in one place because it was edited in
another. F# starts from the opposite default. let binds a value that does
not change — rebinding the same name in the same scope is an error, not an assignment. A
record has no setters; to produce a record that differs in one field you make a new one with
copy-and-update, { r with field = v }, and the original stays exactly
as it was. A C# developer has met this idea already: C# records also have with.
F# gives every record that, and makes immutability the default rather than the opt-in. This dive covers
let, copy-and-update, and one distinction that trips a C# reader: shadowing
is a new binding, not mutation of an old one.
let binds an immutable value
The motivation for the default is concrete. In C#, two names can refer to the same mutable object, and a change through one is visible through the other — aliasing. The F# default removes the surprise: a bound value does not change, so there is nothing to alias into a bug.
C# — a shared mutable, edited in one place
var a = new List<int> { 1, 2, 3 }; var b = a; // same list, not a copy b.Add(4); // a.Count == 4 — a changed through b
F# — an immutable value, summed
let total = [1..5] |> List.sum // val total : int = 15 // total is bound once and never changes; // nothing else can edit it from a distance
- let
- binds a value that does not change; it is not a reassignable
var. - aliasing
- the C# hazard of two names for one mutable object; an immutable value cannot be aliased into a bug.
- no setters
- a record's fields are read-only; a changed record is a new value.
An immutable binding cannot drift. The value a name held when it was bound is the value it holds everywhere, for as long as the name is in scope.
copy-and-update: { r with field = v }
To produce a record that differs in one field, F# makes a new record from an old one with
copy-and-update. The bridge a C# developer already knows: C# records carry the same
with expression, and it behaves the same way — a new value, the source
unchanged. The two languages are shown side by side; the only difference is that in F# every record has this,
as the default.
C# — record with-expression
record Point(double X, double Y); var p = new Point(1, 2); var p2 = p with { Y = 9 }; // p2 = Point { X = 1, Y = 9 } // p = Point { X = 1, Y = 2 } (unchanged)
F# — copy-and-update
type Point = { X: float; Y: float } let p = { X = 1.0; Y = 2.0 } // val p : Point = { X = 1.0; Y = 2.0 } let p2 = { p with Y = 9.0 } // val p2 : Point = { X = 1.0; Y = 9.0 } p // val it : Point = { X = 1.0; Y = 2.0 } (unchanged)
Copy-and-update is the F# answer to a setter: it does not change p, it
produces p2. The C# developer met the syntax on records; in F# it is how every record
changes.
Change one field of p
The record p = { X = 1.0; Y = 2.0 } is fixed. Pick a field to update. The diagram
shows the new record p2 beside the original: the changed field is highlighted on
p2, every other field is copied unchanged, and p stays
exactly as it was. The readout shows the precise FSI values of both.
{ p with … } · the original is never touched
| update | p2 | p (after) |
|---|---|---|
| { p with Y = 9.0 } | { X = 1.0; Y = 9.0 } | { X = 1.0; Y = 2.0 } |
| { p with X = 8.0 } | { X = 8.0; Y = 2.0 } | { X = 1.0; Y = 2.0 } |
| { p with X = 8.0; Y = 9.0 } | { X = 8.0; Y = 9.0 } | { X = 1.0; Y = 2.0 } |
Whatever field changes, the new record carries the rest by copy and the original is untouched. That is the shape of every “modification” of immutable data: a new value, the source intact.
Shadowing is a new binding, not mutation
Inside a scope, a second let of the same name is allowed — this is
shadowing. It looks like reassignment, and a C# reader can mistake it for one, but it is not:
the new let introduces a different value that hides the earlier one from that
point on. The old value is unchanged; any reference taken before the shadow still sees it.
let demo () = let x = 5 // first binding let x = x + 1 // shadowing: a NEW x = 6, the old one hidden x // val demo : unit -> int demo () // val it : int = 6
let x bindings collide: F# reports error FS0037: Duplicate definition
of value 'x'. (An interactive FSI session is the exception: each entry is its own scope, so successive
let x entries shadow.)The C# habit
Reassign a var; mutate a field; share a reference and edit it in place.
F# today
let is immutable; a change is { r with … }; a re-named value shadows, it does not mutate.
Where this leads
Immutability is the F# default, not a ban. There are times — a tight accumulation loop, a
Dictionary at an interop boundary — where in-place change is the right tool.
The next dive shows how F# admits exactly that, while keeping it from leaking: mutation is
contained.
References
Sources
- Microsoft — Copy and update record expressions — the F#
{ r with … }form. - Microsoft — C# records and
with— the C# recordwithexpression this dive bridges from. - Microsoft — F# values and
let— immutable bindings and shadowing.
Related in this course
- C2.02.3 — Mutation, contained — where in-place change is the right tool.
- C2.02 — Immutability & expressions — the module hub and its three dives.
- C1.02 — let bindings, immutability & shadowing — the same ideas from the language side.