You are viewing a preview of this lesson. Sign in to start learning
Back to Functional Programming with F#

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:

  1. Convert to Celsius
  2. Filter out any below freezing
  3. 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.map creates a new list with transformed values
  • List.filter creates a new list with only matching elements
  • List.average computes the result without modifying anything
  • Original fahrenheitTemps remains 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
  • eval recursively breaks down complex expressions
  • simplify creates 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

  1. F# for Fun and Profit - Expressions and Syntax: https://fsharpforfunandprofit.com/posts/expressions-intro/
  2. Microsoft F# Language Reference - Immutability: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/values/
  3. F# Software Foundation - Learning Resources: https://fsharp.org/learn/

📋 Quick Reference Card

Immutable bindinglet x = 10
Mutable variablelet mutable x = 10
Mutation operatorx <- 15
If expressionif condition then value1 else value2
Match expressionmatch x with | pattern1 -> result1 | pattern2 -> result2
List creation[1; 2; 3; 4]
Cons operator0 :: [1; 2; 3] produces [0; 1; 2; 3]
Pipe operatordata |> 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 functionlet rec funcName params = ...
Unit type() - represents "no value"

Practice Questions

Test your understanding with these questions:

Q1: Complete the F# code to create an immutable binding: ```fsharp {{1}} total = 100 ```
A: ["let"]
Q2: What does this F# code output? ```fsharp let x = 10 let y = x + 5 let x = 20 printfn "%d" y ``` A. 10 B. 15 C. 20 D. 25 E. Compiler error
A: B
Q3: Complete the code to allow mutation: ```fsharp let {{1}} counter = 0 counter <- counter + 1 ```
A: ["mutable"]
Q4: Fill in the blanks to create a record and update it immutably: ```fsharp type Point = { X: int; Y: int } let p1 = { X = 5; Y = 10 } let p2 = { p1 {{1}} X = 8 } ```
A: ["with"]
Q5: What does this if expression evaluate to? ```fsharp let score = 75 let result = if score >= 80 then "Pass" else "Fail" ``` A. true B. false C. Pass D. Fail E. Compiler error
A: D