Skip to content
AgileLogics
5 min read

Immutability in Practice

Prerequisites: Case classes, Collection operations

Scala code usually models change as new values rather than modified ones. This lesson shows the patterns that make that practical.

State as a value

case class Cart(items: Map[String, Int] = Map.empty):
  def add(item: String, qty: Int = 1): Cart =
    copy(items = items.updated(item, items.getOrElse(item, 0) + qty))
  def remove(item: String): Cart = copy(items = items - item)
  def count: Int = items.values.sum

@main def carts(): Unit =
  val empty = Cart()
  val cart = empty.add("apple", 3).add("pear").add("apple").remove("pear")
  println(cart)
  println(cart.count)
  println(empty.count)
Cart(Map(apple -> 4))
4
0

Every operation returns a new Cart; empty is still empty. You get undo, history, and thread safety for free, because no value is ever modified.

Folding over events

A common pattern: the current state is the result of applying events to an initial state, one after another:

enum Event:
  case Deposited(amount: BigDecimal)
  case Withdrew(amount: BigDecimal)

case class Account(balance: BigDecimal, transactions: Int)

def apply(account: Account, event: Event): Account = event match
  case Event.Deposited(a) => Account(account.balance + a, account.transactions + 1)
  case Event.Withdrew(a)  => Account(account.balance - a, account.transactions + 1)

@main def events(): Unit =
  val history = List(Event.Deposited(100), Event.Withdrew(30), Event.Deposited(15))
  println(history.foldLeft(Account(0, 0))(apply))
  println(history.scanLeft(BigDecimal(0))((b, e) => apply(Account(b, 0), e).balance))
Account(85,3)
List(0, 100, 70, 85)

scanLeft is like foldLeft, but keeps every intermediate result — the balance after each event.

When mutation is fine

Local mutation that never escapes a method is harmless and sometimes clearer or faster:

def primesUpTo(n: Int): List[Int] =
  val isComposite = Array.fill(n + 1)(false)
  for
    i <- 2 to n
    if !isComposite(i)
    j <- i * i to n by i
  do isComposite(j) = true
  (2 to n).filterNot(isComposite).toList

@main def sieve(): Unit =
  println(primesUpTo(30))
List(2, 3, 5, 7, 11, 13, 17, 19, 23, 29)

From the outside, primesUpTo is a pure function: same input, same output, no visible side effects.