Introduction
In the last post we talked about case class(es), and their default immutability. In this post we will explore the concept of immutability applied to collections: a List or a Map rather than an Order. One question decides how a whole category of bugs plays out: when you hand a collection to another function, is that function able to mutate what you gave it, whether or not it should?
Python: mutable is the only built-in option
Not much to say here: list and dict are mutable, full stop. The language has no immutable list (tuple is the closest thing, but it is a different type with a different API, not a drop-in immutable list). Passing a list to a function means trusting that function not to mutate it, with nothing enforcing the trust.
def process_items(items: list[str]) -> list[str]:
items.sort() # mutates the CALLER'S list, in place
items.append("done") # so does this
return items
original = ["banana", "apple"]
result = process_items(original)
print(original) # ["apple", "banana", "done"]: the caller's list changed
# too, even though nothing in the call site suggested that.JS/TS arrays: mutable, no persistent-structure story
JavaScript arrays are mutable and have no built-in immutable counterpart, at all. Object.freeze() exists, but is shallow and only throws in strict mode, and readonly array types in TypeScript are, like readonly fields from last post, purely a compile-time fiction.
function processItems(items: readonly string[]): string[] {
// items.sort(): TS correctly refuses to compile this, .sort() isn't
// on ReadonlyArray's type. Genuinely useful.
return [...items].sort();
}
// But readonly is erased at runtime: cast past it and it's gone:
const items: readonly string[] = ["banana", "apple"];
(items as string[]).push("done"); // compiles with the cast, runs, mutates.Java: immutability as a wrapper you have to remember
Collections.unmodifiableList(...), and List.of(...) in modern Java, both produce an immutable list. However, both are still opt-in wrappers around a collections framework where ArrayList, HashMap, and friends remain the mutable defaults everyone reaches first, out of habit.
List<String> items = new ArrayList<>(List.of("banana", "apple"));
void processItems(List<String> items) {
items.sort(Comparator.naturalOrder()); // mutates the caller's list
items.add("done"); // so does this
}
// Nothing about the method signature List<String> tells you whether
// it mutates its argument: you have to read the implementation, or
// remember to defensively wrap every list you don't want touched
// and then, you must remember to enclose the whole call in a try-catch
processItems(Collections.unmodifiableList(items));Scala: immutable unless you ask otherwise
scala.collection.immutable types (List, Map, Set, Vector) are what's in scope by default, without need to import anything. Every "mutating" operation returns a new, immutable collection, leaving the original untouched. This isn't a naive full-copy underneath, either: these are persistent data structures that share unchanged structure between versions, so the cost stays close to O(log n) rather than O(n) per update.
def processItems(items: List[String]): List[String] =
items.sorted :+ "done" // returns a NEW list: sorted, with an
// element appended. items itself is
// completely untouched by this call.
val original = List("banana", "apple")
val result = processItems(original)
println(result) // List(banana, apple, done), unchanged.
println(original) // List(banana, apple), unchanged.Reaching for a mutable collection when you absolutely need one (a tight numeric loop, an FFI boundary) is still one import away: scala.collection.mutable.ListBuffer, for instance. The difference from the other three languages isn't that Scala forbids mutation but, rather, that mutation has to be explicitly requested, rather than being the thing you get unless you remember to guard against it.
The bug class this prevents
The failure mode running through the Python and Java examples has a name: aliasing bugs. They are among the nastier ones to track down because they are non-local. Function A passes a list to function B, B mutates it for its own reasons, and A's behaviour changes for a cause that appears nowhere in A. Scala closes that off by handing you immutable structures built to be passed around and rebuilt cheaply.
Why default immutability suits AI-generated code
Aliasing bugs are exactly what an agent introduces without noticing, because the mistake is invisible at the call site that causes it. items.sort() looks fine in isolation, and an agent reading only the function it is editing has no way to know a caller three files away depends on that list keeping its original order. Finding out means walking several call chains and filling its context with code that has nothing to do with the task.
In Scala, that same mistake doesn't compile. List has no .sort() method that mutates in place, only .sorted, which returns a new list. The unsafe operation the agent might reach for, simply isn't available on the default type. There's no vigilance required, because there's nothing to be vigilant about.
