Why change is dangerous
Imagine a shared shopping list on the fridge. You cross out “milk” because you bought it; your flatmate, reading the list five minutes earlier, already left to buy milk too. The list changed while someone else was relying on it.
Now imagine every change produces a new list instead, and the old one stays exactly as it was. Whoever is holding the old one still has a consistent picture, and you hand the new one around when you’re done. That’s immutability: once created, data never changes; “changing” it means creating a new version.
Shared mutable state is the common factor in aliasing bugs (a function mutates its argument), temporal coupling (results depend on call order), and data races. Immutability removes the “mutable” half: values can be shared freely, cached, used as map keys, compared by value, and published across threads without locks. The costs are copying and, in JVM/JS, the gap between read-only types and truly immutable data, which is where most real bugs hide.
Tap the line with the bug.
final class Cart {
private(set) var items: [Item] = []
func cheapestItem() -> Item? {
items.sort { $0.priceCents < $1.priceCents }
return items.first
}
}class Cart {
val items = mutableListOf<Item>()
fun cheapestItem(): Item? {
items.sortBy { it.priceCents }
return items.firstOrNull()
}
}class Cart {
private final List<Item> items = new ArrayList<>();
Item cheapestItem() {
items.sort(Comparator.comparingInt(Item::priceCents));
return items.isEmpty() ? null : items.get(0);
}
}class Cart {
readonly items: Item[] = [];
cheapestItem(): Item | undefined {
this.items.sort((a, b) => a.priceCents - b.priceCents);
return this.items[0];
}
}Hint: The user added items in a specific order, and the cart screen lists them in that order. What happens to that order after cheapestItem() is called?
sort rearranges the cart’s own list in place. A question (“which is cheapest?”) secretly changed the answer to a different question (“in what order did the user add things?”). Every screen that shows the cart now shows it sorted by price. readonly in TypeScript and val in Kotlin don’t help: they stop reassigning the property, not changing the list.
Answer questions without changing anything:
func cheapestItem() -> Item? {
items.min { $0.priceCents < $1.priceCents }
}fun cheapestItem(): Item? = items.minByOrNull { it.priceCents }Optional<Item> cheapestItem() {
return items.stream().min(Comparator.comparingInt(Item::priceCents));
}cheapestItem(): Item | undefined {
return this.items.reduce<Item | undefined>(
(min, item) => (min === undefined || item.priceCents < min.priceCents ? item : min), undefined);
}When you do need a sorted list, ask for a sorted copy: Swift sorted, Kotlin sortedBy, Java stream().sorted(), TypeScript toSorted (ES2023) or [...items].sort(…).
The rule that prevents this kind of bug: functions that answer a question don’t change anything, and functions that change something say so in their name (sort() vs sorted(), reverse() vs reversed()). Immutable data makes the rule automatic: if the list can’t be changed, a careless sort doesn’t compile.
The tools in each language
| Swift | Kotlin | Java | TypeScript | |
|---|---|---|---|---|
| A property that can’t be reassigned | let | val | final | readonly |
| A data type that’s easy to keep immutable | struct with let properties | data class with vals | record | type with readonly fields |
| A list that can’t be changed | let array (a value) | List (read-only view, see below) | List.of, List.copyOf | ReadonlyArray, readonly T[] |
| A changed copy | var copy = x; copy.name = … | x.copy(name = …) | a new record (or a withName method) | { ...x, name: … } |
Swift has the easiest time: a struct with let properties is a value, so every assignment is a copy and nothing is ever shared. The other languages share objects by reference, so immutability is a promise each type has to keep.
Immutability and threads
An immutable object, safely published, needs no synchronization: every thread sees the same, complete state. On the JVM, final fields carry a guarantee from the Java Memory Model that a constructed object’s final fields are visible to other threads without locks (as long as this didn’t escape the constructor). Swift 6 strict concurrency encodes the same idea in types: a struct of Sendable values, or a final class with only let Sendable properties, is Sendable and may cross isolation boundaries. In JavaScript, workers share nothing by default, and immutable messages make structured cloning predictable. In practice: make the things you pass between threads (events, state snapshots, configuration) immutable values, and keep mutation inside one owner (an actor, a single thread, a reducer).
- Shared mutable state causes aliasing bugs, order dependence and data races. Immutable values can be shared, cached, used as keys and passed between threads freely.
- Query methods must not mutate:
min/minByOrNull/stream().min/reduceor sorted copies (sorted,sortedBy,toSorted), never in-placesorton shared state. - Thread safety: JVM
finalfields get safe-publication guarantees; SwiftSendablestructs andlet-only final classes cross isolation boundaries.
Check this part
A method that finds the cheapest item calls sort on the cart's own list. What's the problem?
What does "immutable" mean?
Why can immutable objects be shared between threads without locks?