Immutability defaults
Shared mutable state is where bugs go to hide: a value changes under code
that assumed it was stable, and nothing in the type or the call site warned
you. Making immutability the default converts a whole class of aliasing
bugs into compile errors, and makes the ones that remain cheap to reason
about because a value you hold cannot change beneath you.
Method
- Default every field to read-only. Use
final in Java, val in
Kotlin, readonly in TypeScript, const bindings in JS, and
@dataclass(frozen=True) in Python. Reach for a mutable field only when
a profiler or a real hot loop demands it, and write the comment saying
why.
- Copy on write, return the new value. Instead of
list.sort() in
place, return a sorted copy; instead of mutating an argument, build and
return a fresh object. Every caller keeps its original, so no distant
reader breaks when you reorder or drop an element.
- Freeze at the boundary. Data crossing into your module gets
Object.freeze, tuple(...), or a defensive copy, so a caller's later
mutation cannot reach into your state. This is cheapest exactly where
untrusted data first arrives.
- Prefer persistent structures over deep copies for large data. Immer,
Immutable.js, or pyrsistent share the unchanged structure and copy only
the changed path, so a one-element update on a 10k map costs one small
allocation, not 10k.
- Model updates as transformations. A reducer that takes state and an
event and returns new state is testable and replayable; an object that
mutates itself in place is neither. Redux and event sourcing both stand
on this shape.
- Watch the aliasing traps: a default mutable argument
(
def f(x=[])), a getter that hands back the internal list, two names
bound to one array. Each lets an edit in one place surface as a failure
somewhere unrelated.
Litmus tests
- Can two threads read this object with no lock and no surprise?
- If a caller holds a reference you returned, can their later edit change
your state?
- Does any method named
copy actually share the same underlying array or
map?
Boundaries
Hot paths with measured allocation pressure and large numeric buffers
(NumPy arrays, audio frames) are the honest exceptions: mutate them
locally, keep them un-shared, and document the ownership. Language idiom
wins too: fighting Go's value semantics or Rust's borrow checker to force a
foreign style costs more than the immutability buys.
1---2name: immutability-defaults3description: Make immutability the default so aliasing bugs turn into compile errors or no-ops instead of action at a distance. Use when designing data structures, state updates, or any value shared across threads or call sites.4---56# Immutability defaults78Shared mutable state is where bugs go to hide: a value changes under code9that assumed it was stable, and nothing in the type or the call site warned10you. Making immutability the default converts a whole class of aliasing11bugs into compile errors, and makes the ones that remain cheap to reason12about because a value you hold cannot change beneath you.1314## Method15161. **Default every field to read-only.** Use `final` in Java, `val` in17 Kotlin, `readonly` in TypeScript, `const` bindings in JS, and18 `@dataclass(frozen=True)` in Python. Reach for a mutable field only when19 a profiler or a real hot loop demands it, and write the comment saying20 why.212. **Copy on write, return the new value.** Instead of `list.sort()` in22 place, return a sorted copy; instead of mutating an argument, build and23 return a fresh object. Every caller keeps its original, so no distant24 reader breaks when you reorder or drop an element.253. **Freeze at the boundary.** Data crossing into your module gets26 `Object.freeze`, `tuple(...)`, or a defensive copy, so a caller's later27 mutation cannot reach into your state. This is cheapest exactly where28 untrusted data first arrives.294. **Prefer persistent structures over deep copies for large data.** Immer,30 Immutable.js, or pyrsistent share the unchanged structure and copy only31 the changed path, so a one-element update on a 10k map costs one small32 allocation, not 10k.335. **Model updates as transformations.** A reducer that takes state and an34 event and returns new state is testable and replayable; an object that35 mutates itself in place is neither. Redux and event sourcing both stand36 on this shape.376. **Watch the aliasing traps:** a default mutable argument38 (`def f(x=[])`), a getter that hands back the internal list, two names39 bound to one array. Each lets an edit in one place surface as a failure40 somewhere unrelated.4142## Litmus tests4344- Can two threads read this object with no lock and no surprise?45- If a caller holds a reference you returned, can their later edit change46 your state?47- Does any method named `copy` actually share the same underlying array or48 map?4950## Boundaries5152Hot paths with measured allocation pressure and large numeric buffers53(NumPy arrays, audio frames) are the honest exceptions: mutate them54locally, keep them un-shared, and document the ownership. Language idiom55wins too: fighting Go's value semantics or Rust's borrow checker to force a56foreign style costs more than the immutability buys.