Immutability: let vs mutable
The scenario
In C#, this raises no questions at all:
int counter = 0;
counter = counter + 1;
counter = counter + 1;
A local variable is freely reassigned, by default, with no special keyword. It's so natural in C# that you don't even notice it anymore.
Now try, in dotnet fsi, the equivalent that seems obvious:
let counter = 0
counter <- counter + 1
This fails. Not because the syntax is wrong, but because let in F# doesn't create a variable in the C# sense: it creates an immutable binding, a fixed association between a name and a value, which can no longer change once established.
Why F# chose this by default
In a language like C#, a mutable variable can be modified at any time, including by code you don't have in view at the moment you read it — another thread, a method called in between, a hidden side effect. F# flips the default assumption: a value, once bound by let, stays what it was when it was created, for as long as it's visible. This eliminates by construction a whole class of bugs caused by shared values changing under the feet of some other piece of code — a problem that's especially sensitive as soon as multiple threads are involved.
F# isn't a purely functional language — it allows mutation when it's needed — but immutability is the default choice there, and mutation has to be explicitly requested.
Exercise — predict then verify
Before running anything, mentally answer this question: what will fsi display if you type these two lines one after the other?
let counter = 0;;
counter <- counter + 1;;
Now check it in fsi. The first line succeeds normally:
val counter: int = 0
The second fails at compile time, before it even runs. The exact message may vary slightly depending on the compiler version, but its meaning stays constant: counter isn't mutable, so the <- operator can't apply to it. It's the compiler that blocks this code, not an exception occurring at runtime — the error is caught before the program even runs.
Making a value explicitly mutable
To get a reassignable-variable behavior comparable to C#'s, you have to ask for it with the mutable keyword:
let mutable counter = 0
counter <- counter + 1
Two differences from the C# example at the start, worth telling apart:
- the reassignment operator is
<-, not=(in F#,=in an expression tests equality, it never reassigns anything); mutablehas to be written explicitly at the moment the binding is defined — you can't retroactively make aletmutable after the fact if it wasn't declared that way.
Now predict the result of this sequence, then check it in fsi:
let mutable counter = 0;;
counter <- counter + 1;;
counter <- counter + 1;;
counter;;
Expected output for the first three lines (the assignment itself doesn't produce anything interesting, only unit, the F# type meaning "nothing meaningful to return"):
val mutable counter: int = 0
val it: unit = ()
val it: unit = ()
Pay attention to the first line: the word mutable must appear in fsi's response, confirming that counter is indeed declared mutable — but its exact formatting (spacing, order of elements) can vary slightly depending on the version of dotnet fsi installed on your machine. Don't worry if the text displayed on your machine differs a little character for character; only the presence of the word mutable matters here, unlike the other outputs in this module, which do have to match exactly.
And for the last line, which simply reads the current value of counter:
val it: int = 2
What this changes about how you write code
In C#, modifying a value is the default tool for advancing a computation — a for loop that increments a counter, an accumulator updated on every iteration. In F#, the dominant habit is different: rather than modifying an existing value, you produce a new value from the old one. mutable remains available and legitimate when mutation genuinely reflects the problem being solved — but it's no longer the starting reflex, it becomes a conscious, localized choice.
What you just did
You saw that a plain let refuses any reassignment via <-, understood that this refusal is a deliberate F# design decision rather than an accidental limitation, then observed how mutable explicitly brings that behavior back when you genuinely need it.
In the next module, you'll leave the question of mutation behind to look at another everyday tool: how to chain and build functions with the |> and >> operators, to replace the nested calls C# has gotten you used to.
Check your understanding
In dotnet fsi, you type `let counter = 0;;` then `counter <- counter + 1;;`. What happens with the second line?
What's the real reason F# makes a value immutable by default, rather than just an arbitrary syntax choice?
You wrote let mutable counter = 0. A colleague then wants to make another value, let total = 100 (already defined earlier without mutable), reassignable with <-. Is that possible without redefining total?
Want to hear about the next modules?
The Academy stays free and open-access, no sign-up required. If you'd just like to be notified by email when a new module ships, here you go — no obligation, unsubscribe anytime with one click.