Immutability & Expressions
Master F# fundamentals: immutable values, pure functions, type inference, and expression-based programming as the foundation of functional thinking.
Immutability & Expressions in F#
Master the fundamental concepts of immutability and expressions in F# with free flashcards and spaced repetition practice. This lesson covers immutable data structures, expression-based programming, and functional transformations—essential concepts for writing robust, maintainable F# code. Understanding these principles will transform how you approach problem-solving in functional programming.
Welcome to Immutability & Expressions! 💻
Welcome to one of the most transformative concepts in functional programming! If you've come from imperative languages like C# or Java, you're about to experience a paradigm shift. In F#, immutability isn't just a best practice—it's the default. Everything is an expression that evaluates to a value, not a statement that executes a command. This fundamental difference eliminates entire categories of bugs and makes your code dramatically easier to reason about.
Think of immutability like writing with pen instead of pencil. Once you write something down, it's permanent. If you need to make a change, you create a new document with the modifications rather than erasing the original. This might seem limiting at first, but it's actually liberating—you never have to worry about unexpected changes happening behind your back!
Core Concepts: The Foundation of Functional F# 🏗️
What is Immutability? 🔒
Immutability means that once a value is created, it cannot be changed. In F#, all bindings are immutable by default. When you write let x = 5, you're not creating a "variable" in the traditional sense—you're creating a binding between the name x and the value 5. This binding is permanent.
let x = 10
// x <- 15 // ❌ This would cause a compiler error!
// x is immutable by default
let y = x + 5 // ✅ Instead, create new values
printfn "%d" y // Prints: 15
Why immutability matters:
- Thread safety: Immutable data can be safely shared between threads without locks
- Predictability: Functions can't have hidden side effects that change your data
- Easier debugging: When something goes wrong, you know the data hasn't been modified
- Time travel: You can keep old versions of data structures for undo/redo functionality
💡 Memory Tip: Think "Immutable = Immovable = Inseparable from its value"
Mutable Values (When You Really Need Them) 🔓
F# does support mutability when necessary, but you must explicitly opt-in using the mutable keyword:
let mutable counter = 0
counter <- counter + 1 // ✅ Mutation allowed with <-
printfn "%d" counter // Prints: 1
⚠️ Best Practice: Use mutable values sparingly! They're useful for performance optimization or when interoperating with imperative code, but immutability should be your default choice.
Everything is an Expression 📊
In F#, everything evaluates to a value. Unlike statement-based languages where if, for, and try are statements that execute commands, F# treats these as expressions that return values:
// if is an expression that returns a value
let result = if 5 > 3 then "bigger" else "smaller"
printfn "%s" result // Prints: bigger
// This is why both branches must return the same type!
let score = 85
let grade = if score >= 90 then "A" else "B" // Both branches return strings
Key difference from imperative languages:
| C# (Statements) | F# (Expressions) |
|---|---|
string grade;
if (score >= 90) {
grade = "A";
} else {
grade = "B";
}
|
let grade =
if score >= 90
then "A"
else "B"
|
| Variable must be declared first Value assigned later Multiple statements |
Single expression Evaluates to a value No variable declaration |
The Unit Type: When There's No Value 🎯
Sometimes you need to perform side effects (like printing to console) that don't return a meaningful value. F# uses the unit type () for this:
let printMessage () = // Takes unit, returns unit
printfn "Hello, World!" // printfn returns ()
() // Explicit unit return
// Calling a unit function
printMessage () // Pass unit as argument
The unit type is F#'s way of saying "this function does something but doesn't produce a useful value." It's similar to void in C#, but because it's a real type, you can work with it in expressions.
Expression-Based Control Flow 🔄
Match expressions are F#'s superpower for pattern matching. They're like switch statements on steroids:
let describeNumber n =
match n with
| 0 -> "zero"
| 1 -> "one"
| 2 -> "two"
| n when n < 0 -> "negative"
| n when n % 2 = 0 -> "even"
| _ -> "odd" // _ is the wildcard pattern
let result = describeNumber 4
printfn "%s" result // Prints: even
Every match expression must be exhaustive—the compiler ensures you've handled all possible cases. This eliminates a huge class of runtime errors!
Immutable Collections 📚
F# provides powerful immutable collection types:
Lists (immutable linked lists):
let numbers = [1; 2; 3; 4; 5]
let moreNumbers = 0 :: numbers // :: is "cons" - prepends element
printfn "%A" moreNumbers // Prints: [0; 1; 2; 3; 4; 5]
// numbers is unchanged!
Arrays (mutable, but typically used immutably):
let squares = [|1; 4; 9; 16; 25|]
let doubled = Array.map (fun x -> x * 2) squares
// squares is unchanged
Records (immutable data structures):
type Person = { Name: string; Age: int }
let john = { Name = "John"; Age = 30 }
let olderJohn = { john with Age = 31 } // Copy with one field changed
// john is unchanged!
Recursive Functions: The Immutable Loop 🔁
Without mutation, how do you loop? With recursion! F# optimizes tail-recursive functions to be as efficient as loops:
// Recursive sum
let rec sum list =
match list with
| [] -> 0
| head :: tail -> head + sum tail
let total = sum [1; 2; 3; 4; 5]
printfn "%d" total // Prints: 15
The rec keyword tells F# this function calls itself. The compiler can optimize tail-recursive functions (where the recursive call is the last operation) into efficient loops.
Functional Transformations 🔄
Instead of mutating data in loops, F# encourages transformations using higher-order functions:
let numbers = [1; 2; 3; 4; 5]
// Map: transform each element
let doubled = numbers |> List.map (fun x -> x * 2)
// [2; 4; 6; 8; 10]
// Filter: keep elements matching a predicate
let evens = numbers |> List.filter (fun x -> x % 2 = 0)
// [2; 4]
// Fold: accumulate a result
let sum = numbers |> List.fold (+) 0
// 15
The |> operator is the pipe operator—it passes the result of one expression as the last argument to the next function. This creates readable left-to-right data processing pipelines!
🔧 Try this: Take a list [1; 2; 3; 4; 5] and use List.map, List.filter, and List.sum to find the sum of all even numbers after doubling them. (Answer: List.map (*2) >> List.filter (fun x -> x % 2 = 0) >> List.sum gives 30, but all were already even after doubling, so sum of doubled list)
Copy-and-Update Patterns 📝
When you need to "modify" immutable data, you create a copy with changes:
type Point = { X: int; Y: int }
let p1 = { X = 5; Y = 10 }
let p2 = { p1 with X = 8 } // Copy p1, but change X
// p1 is { X = 5; Y = 10 }
// p2 is { X = 8; Y = 10 }
This pattern is so common that F# has special syntax for it with records. Under the hood, this is efficient—F# can share unchanged parts of the data structure.
Expression vs Statement Mindset 🧠
The shift from statements to expressions changes how you think:
Statement mindset (imperative):
let mutable result = 0
for i in 1..10 do
result <- result + i
printfn "%d" result
Expression mindset (functional):
let result = [1..10] |> List.sum
printfn "%d" result
The functional version is:
- More concise
- More declarative (says what not how)
- Less error-prone (no mutation to track)
- More composable (easy to extend the pipeline)
🌍 Real-world analogy: Statement-based code is like giving turn-by-turn directions ("Go forward 2 blocks, turn left, go 1 block..."). Expression-based code is like describing the destination ("The coffee shop at Main and 5th"). Both get you there, but one describes the journey, the other describes the result.
Detailed Examples 🎯
Example 1: Processing Data Without Mutation 📊
Let's say you have a list of temperatures in Fahrenheit and want to:
- Convert to Celsius
- Filter out any below freezing
- Calculate the average
Imperative approach (with mutation):
let fahrenheitTemps = [32.0; 68.0; 86.0; 23.0; 77.0; 18.0]
let mutable celsiusTemps = []
for temp in fahrenheitTemps do
let celsius = (temp - 32.0) * 5.0 / 9.0
if celsius >= 0.0 then
celsiusTemps <- celsius :: celsiusTemps
let mutable sum = 0.0
let mutable count = 0
for temp in celsiusTemps do
sum <- sum + temp
count <- count + 1
let average = sum / float count
printfn "Average: %.2f°C" average
Functional approach (with expressions):
let fahrenheitTemps = [32.0; 68.0; 86.0; 23.0; 77.0; 18.0]
let average =
fahrenheitTemps
|> List.map (fun f -> (f - 32.0) * 5.0 / 9.0) // Convert
|> List.filter (fun c -> c >= 0.0) // Filter
|> List.average // Calculate
printfn "Average: %.2f°C" average
The functional version is:
- 3 lines instead of 13
- No mutable state to track
- Reads like a pipeline of transformations
- Each step is independently testable
Why this works:
List.mapcreates a new list with transformed valuesList.filtercreates a new list with only matching elementsList.averagecomputes the result without modifying anything- Original
fahrenheitTempsremains unchanged throughout
Example 2: Building Complex Data Structures Immutably 🏗️
Let's model a shopping cart where items can be added, removed, or updated:
type Product = { Id: int; Name: string; Price: decimal }
type CartItem = { Product: Product; Quantity: int }
type ShoppingCart = { Items: CartItem list }
// Create a new cart
let emptyCart = { Items = [] }
// Add an item (returns new cart)
let addItem product quantity cart =
let newItem = { Product = product; Quantity = quantity }
{ cart with Items = newItem :: cart.Items }
// Update quantity (returns new cart)
let updateQuantity productId newQuantity cart =
let updatedItems =
cart.Items
|> List.map (fun item ->
if item.Product.Id = productId then
{ item with Quantity = newQuantity }
else
item)
{ cart with Items = updatedItems }
// Remove an item (returns new cart)
let removeItem productId cart =
let filteredItems =
cart.Items
|> List.filter (fun item -> item.Product.Id <> productId)
{ cart with Items = filteredItems }
// Calculate total (expression!)
let calculateTotal cart =
cart.Items
|> List.sumBy (fun item ->
item.Product.Price * decimal item.Quantity)
// Usage
let apple = { Id = 1; Name = "Apple"; Price = 0.99M }
let banana = { Id = 2; Name = "Banana"; Price = 0.59M }
let cart1 = emptyCart
let cart2 = addItem apple 3 cart1
let cart3 = addItem banana 2 cart2
let cart4 = updateQuantity 1 5 cart3 // Update apple quantity
printfn "Cart2 total: $%.2f" (calculateTotal cart2) // $2.97
printfn "Cart3 total: $%.2f" (calculateTotal cart3) // $4.15
printfn "Cart4 total: $%.2f" (calculateTotal cart4) // $5.93
// cart1, cart2, cart3 are all still available and unchanged!
Key insights:
- Each operation returns a new cart
- Old versions remain accessible (useful for undo, comparison, debugging)
- No possibility of accidental cart corruption
- Thread-safe by default—multiple threads can safely read any cart version
Example 3: Recursive Data Processing 🌲
Let's implement a simple expression evaluator that demonstrates immutability and expressions working together:
type Expr =
| Number of int
| Add of Expr * Expr
| Multiply of Expr * Expr
| Subtract of Expr * Expr
// Recursive evaluation function (everything is an expression!)
let rec eval expr =
match expr with
| Number n -> n
| Add (left, right) -> (eval left) + (eval right)
| Multiply (left, right) -> (eval left) * (eval right)
| Subtract (left, right) -> (eval left) - (eval right)
// Build an expression: (5 + 3) * (10 - 2)
let expression =
Multiply(
Add(Number 5, Number 3),
Subtract(Number 10, Number 2)
)
let result = eval expression
printfn "Result: %d" result // Prints: Result: 64
// Let's simplify expressions
let rec simplify expr =
match expr with
| Add (Number 0, e) -> simplify e // 0 + x = x
| Add (e, Number 0) -> simplify e // x + 0 = x
| Multiply (Number 1, e) -> simplify e // 1 * x = x
| Multiply (e, Number 1) -> simplify e // x * 1 = x
| Multiply (Number 0, _) -> Number 0 // 0 * x = 0
| Multiply (_, Number 0) -> Number 0 // x * 0 = 0
| Add (left, right) -> Add(simplify left, simplify right)
| Multiply (left, right) -> Multiply(simplify left, simplify right)
| Subtract (left, right) -> Subtract(simplify left, simplify right)
| expr -> expr
let complex = Add(Number 0, Multiply(Number 1, Add(Number 5, Number 3)))
let simplified = simplify complex // Becomes: Add(Number 5, Number 3)
printfn "Simplified result: %d" (eval simplified) // Prints: 8
What's happening here:
- Expressions are immutable discriminated unions
evalrecursively breaks down complex expressionssimplifycreates new simplified expressions without changing originals- Pattern matching makes the logic crystal clear
- No mutable state anywhere!
Example 4: Pipeline Transformations 🔄
Let's process a log file to find the most common error types:
type LogEntry = { Timestamp: string; Level: string; Message: string }
let logData = [
{ Timestamp = "2024-01-01 10:00"; Level = "ERROR"; Message = "Connection timeout" }
{ Timestamp = "2024-01-01 10:01"; Level = "INFO"; Message = "User login" }
{ Timestamp = "2024-01-01 10:02"; Level = "ERROR"; Message = "Database error" }
{ Timestamp = "2024-01-01 10:03"; Level = "ERROR"; Message = "Connection timeout" }
{ Timestamp = "2024-01-01 10:04"; Level = "WARN"; Message = "Slow query" }
{ Timestamp = "2024-01-01 10:05"; Level = "ERROR"; Message = "Connection timeout" }
]
let errorReport =
logData
|> List.filter (fun entry -> entry.Level = "ERROR") // Only errors
|> List.map (fun entry -> entry.Message) // Extract messages
|> List.groupBy id // Group identical messages
|> List.map (fun (msg, occurrences) -> (msg, List.length occurrences))
|> List.sortByDescending snd // Sort by count
|> List.take 3 // Top 3
printfn "Top 3 errors:"
errorReport
|> List.iter (fun (msg, count) -> printfn " %s: %d times" msg count)
// Output:
// Top 3 errors:
// Connection timeout: 3 times
// Database error: 1 times
The beauty of pipelines:
- Each step is a pure transformation
- Easy to add, remove, or reorder steps
- Each step can be tested independently
- No temporary variables cluttering the code
- Reads naturally from top to bottom
💡 Did you know? The pipeline operator |> is so popular that many imperative languages (C#, JavaScript, Elixir) have adopted similar features! F# pioneered this approach in the .NET ecosystem.
Common Mistakes ⚠️
Mistake 1: Trying to Mutate Immutable Values
❌ Wrong:
let x = 10
x <- 15 // Compiler error: x is not mutable
✅ Right:
let x = 10
let x = 15 // Shadow the previous binding with a new one
// Or, if you truly need mutation:
let mutable y = 10
y <- 15 // OK, y is declared mutable
Mistake 2: Forgetting the else Branch
❌ Wrong:
let result = if score > 90 then "A" // Compiler error: if/then must have else
✅ Right:
let result = if score > 90 then "A" else "B"
// Or, if you don't need a value:
if score > 90 then printfn "Excellent!" else ()
Why? Since if is an expression, both branches must return a value of the same type. Without an else, the compiler doesn't know what value to return when the condition is false.
Mistake 3: Using the Wrong Operator for Updates
❌ Wrong:
type Person = { Name: string; Age: int }
let john = { Name = "John"; Age = 30 }
john.Age <- 31 // Error: record fields are immutable by default
✅ Right:
let john = { Name = "John"; Age = 30 }
let olderJohn = { john with Age = 31 } // Create new record
Mistake 4: Confusing = and <-
❌ Wrong:
let mutable x = 10
x = 15 // This is a comparison, returns false!
✅ Right:
let mutable x = 10
x <- 15 // This is mutation
// Or use = for binding:
let y = 15 // This creates a new immutable binding
Mistake 5: Not Capturing the Result of Transformations
❌ Wrong:
let numbers = [1; 2; 3; 4; 5]
List.map (fun x -> x * 2) numbers // Result is discarded!
printfn "%A" numbers // Still [1; 2; 3; 4; 5]
✅ Right:
let numbers = [1; 2; 3; 4; 5]
let doubled = List.map (fun x -> x * 2) numbers // Capture result
printfn "%A" doubled // [2; 4; 6; 8; 10]
Remember: Transformations return new collections; they don't modify the originals!
Mistake 6: Overusing Mutable State
❌ Wrong (but legal):
let mutable sum = 0
let mutable count = 0
for item in items do
sum <- sum + item
count <- count + 1
let average = sum / count
✅ Right:
let average = List.average items
// Or for more complex logic:
let average = items |> List.fold (+) 0 |> fun sum -> sum / List.length items
Why? The mutable version is harder to parallelize, test, and reason about. The functional version is clearer and safer.
Key Takeaways 🎯
✅ Immutability is the default in F#. Values cannot change once created.
✅ Everything is an expression that evaluates to a value, including if, match, and function bodies.
✅ Use let for immutable bindings, let mutable only when necessary, and <- for mutation.
✅ Transformations create new data instead of modifying existing data in place.
✅ The pipe operator (|>) enables readable left-to-right data processing pipelines.
✅ Pattern matching with match is exhaustive and powerful for working with data.
✅ Copy-and-update syntax ({ record with Field = value }) makes it easy to create modified versions of records.
✅ Recursion replaces loops for processing collections without mutation.
✅ Higher-order functions like map, filter, and fold are the functional way to process collections.
✅ Immutability enables safety: No unexpected changes, easier debugging, natural thread safety.
📚 Further Study
- F# for Fun and Profit - Expressions and Syntax: https://fsharpforfunandprofit.com/posts/expressions-intro/
- Microsoft F# Language Reference - Immutability: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/values/
- F# Software Foundation - Learning Resources: https://fsharp.org/learn/
📋 Quick Reference Card
| Immutable binding | let x = 10 |
| Mutable variable | let mutable x = 10 |
| Mutation operator | x <- 15 |
| If expression | if condition then value1 else value2 |
| Match expression | match x with | pattern1 -> result1 | pattern2 -> result2 |
| List creation | [1; 2; 3; 4] |
| Cons operator | 0 :: [1; 2; 3] produces [0; 1; 2; 3] |
| Pipe operator | data |> function1 |> function2 |
| Record copy-update | { record with Field = newValue } |
| Map (transform) | List.map (fun x -> x * 2) list |
| Filter (select) | List.filter (fun x -> x > 5) list |
| Fold (accumulate) | List.fold (+) 0 list |
| Recursive function | let rec funcName params = ... |
| Unit type | () - represents "no value" |