D22 · Mutable vs Immutable

By the end of this lesson you will be able to look at any Dart value and say where it lives, who else can reach it and whether it can change. You will know exactly what final, const, List.unmodifiable, UnmodifiableListView and a defensive copy do to memory, and you will be able to write an immutable class, copy a nested structure correctly and avoid the classic bugs (shared inner lists, lost map keys, leaked internals).

1. Variables, objects and references

A variable is a small sticky note. An object is a house. A note for a number can hold the number itself (like writing "5" on the note), but for a list the note only holds the address of the house. If you copy the note, you get a second note with the SAME address: two notes, one house. If somebody repaints the house, both notes now lead to a repainted house.

Some words, defined once:

Watch aliasing happen. A list is mutable; the line var b = a; copies the arrow only.

Now the same idea with a number. An int, double and bool are immutable values: nothing in Dart can edit the number 5 in place. y++ means y = y + 1: compute a new number, store it in the box named y.

A String is a heap object, but it is immutable too. Look at what += really does.

Assigning a variable that holds an object copies the arrow. Whether you can see a change through the other variable depends only on whether the object is mutable. Immutable objects (numbers, bool, String, DateTime, Duration, Uri, records of immutable parts, const collections) are safe to share; mutable ones (List, Map, Set, StringBuffer, any class with non-final fields) must be shared with care.
Mutable (can change in place)Immutable (every change makes a new object)
List, Map, Set (plain, growable or fixed-length), Queue, StringBuffer, Random, any class with a non-final field int, double, num, bool, BigInt, String, null, DateTime, Duration, Uri, enum values, const collections, List.unmodifiable results, records (shallowly), classes with only final fields
final is not the same as immutable. final list = [1, 2]; only forbids list = somethingElse;. list.add(3) is still allowed. The next section makes the three ideas separate.
In the current Dart VM small integers are usually stored directly inside the variable's slot (no separate heap object), while lists, maps and strings are heap objects reached through a pointer. This is an optimisation detail: from the language's point of view every value is an object and ints are simply immutable, which is why they never show aliasing. Dart's int is 64-bit on native platforms and a JavaScript number on the web; immutability is the same on both.

2. final vs const vs an immutable object

final is a name tag glued to a box: you can never move the tag to another box, but you can still rearrange what is inside the box. const is a sealed, shrink-wrapped box made in the factory (compile time): the tag cannot move AND the contents cannot change. An "immutable object" is any box whose contents cannot be rearranged, however you got it.
What it fixesWhen the value is knownObject content mutable?
var x = ...nothingrun timedepends on the object
final x = ...the variable (assign once)run time (e.g. DateTime.now())depends on the object: a final list can still grow
const x = ...the variable and the object, deeplycompile time onlyno: const collections throw UnsupportedError
immutable classevery field is final (and holds an immutable value)run time (or compile time with a const constructor)no
Trying to change a const collection is not a compile error: the static type is still List, so the compiler lets add through and it fails at run time with UnsupportedError. The same is true for lists made by List.unmodifiable.
Ask two separate questions: "can this variable point somewhere else?" (final/const) and "can this object change?" (is it a mutable type, and is it const/unmodifiable?).

3. const canonicalisation and identical()

A rubber stamp. Every time you write the same const expression, Dart does not make a new copy of the document: it hands you the one original that was stamped when the program was compiled. Many people hold the same document.

Canonicalisation means: all const objects with the same type and the same contents are ONE object. identical(a, b) asks "are these the very same object?" (it compares arrows, nothing else). == may be customised by a class; for List/Map/Set it is just identity.

Edge cases, all checked by running real Dart:

Canonicalisation needs equal type arguments as well as equal contents, so const <int>[1] and const <num>[1] are different objects. A const object can only contain other const things (numbers, strings, other const objects), which is why const collections are deeply immutable: there is nothing mutable inside to reach.

4. Unmodifiable views vs unmodifiable copies

You have a notebook. Unmodifiable copy = you photocopy the pages, laminate the photocopy and give it away: nobody can write on it, and when you later write in your notebook the photocopy does not change. Unmodifiable view = you give someone a pane of glass in front of your notebook: they cannot write, but they see every new line you write.
Made byCopies elements?Sees later changes of the source?Cost to create
copyList.unmodifiable(x), Set.unmodifiable(x), Map.unmodifiable(x)yes (new frozen object)noO(n)
viewUnmodifiableListView(x), UnmodifiableSetView(x), UnmodifiableMapView(x) (import 'dart:collection';)no (wraps an arrow)yesO(1)

Type your own list and the value to append, then step through. Both the copy and the view are built from the same source list.

Writing through either one fails. Here is the complete list: every mutating method of a list, a map and a set on an unmodifiable object throws UnsupportedError. Reading, searching, toList() and spread still work and give you a normal mutable result.

Shallow only. Freezing a list freezes its slots, not the objects the slots point to. List.unmodifiable([inner])[0].add(2) still changes inner. For a whole nested structure see question q25 (deepFreeze).
The list copy is O(n) because every element reference is copied once. The view stores one arrow (_source); view[i] asks the source for element i, so it costs the same as reading the source (O(1) for a List). A view protects only the reader: whoever still holds the original can mutate it.

5. Immutable classes, copyWith and records

A printed postage stamp. You cannot change a stamp's value; if you need a "stamp worth 5 instead of 2" you print a new one. copyWith is the print-a-new-one button: it copies every field, changes the ones you name and gives you a new object.

Recipe for an immutable class: (1) every field is final (and holds an immutable value), (2) a const constructor when possible, (3) override == and hashCode from the same fields, (4) copyWith for "changes". The package:meta annotation @immutable asks the analyzer to warn if a field is not final; it is only a lint, the language itself enforces nothing beyond final.

A record such as (1, 'a') is also immutable: you cannot assign to r.$1. Records have value equality (== compares the fields), but they are immutable only shallowly:

An immutable class with a List field is not really immutable: final List<int> items; forbids re-pointing items, not items.add(1). Copy and freeze the list in the constructor (section 7).

6. Shallow vs deep copy

Copying a bookshelf. A shallow copy builds a new empty shelf and puts the same books on it: rip a page out of a volume and both shelves have a ripped volume. A deep copy also photocopies every volume.

All of these are shallow: List.of(a), List.from(a), a.toList(), [...a], a.sublist(0), Map.of, Set.of. They copy the outer container (O(n)); inner objects are shared. For a deep copy, copy every level yourself. Type nested rows and watch the arrows (1-3 rows, 1-3 numbers each).

The same sharing is behind a very common bug: List.filled puts one fill object in every slot.

Deep copy means "copy every mutable object you can reach". For general nested data write a recursive function (see the question bank: deepCopy); it costs O(N) for N nodes and cannot handle cycles.

7. Defensive copies

Lending your diary. If you hand over the diary itself, the borrower can scribble in it. A defensive copy is a photocopy you give them (or a glass pane); your original is safe.

When a class stores a list that came from outside, or returns its internal list, the outside code keeps a rope to the class's private state. Fix it at the two doors: copy on the way in (List.of(items) in the constructor) and hand out a read-only view on the way out (UnmodifiableListView(_items), O(1)).

Copy on the way in is O(n) once. Returning a view costs O(1) per call; returning List.of(_items) from the getter would cost O(n) every call and the caller could not see later changes.

8. == / hashCode and mutated keys

A library: the catalogue says "books about cats are on shelf 3". If somebody secretly rewrites the volume's title to "dogs" but leaves it on shelf 3, you will look for it on shelf 4 and never find it - although the volume is still in the building.

A Set or Map uses an element's hashCode to choose a bucket (a numbered compartment); == then picks the right element inside it. The contract: equal objects must have equal hash codes, and the hash code of an object stored in a hash collection must never change. The easy way to guarantee it: build ==/hashCode only from final fields (Object.hash(a, b) combines several). A List as a key is safe in a different way: it uses identity, so it ignores contents.

The picture below is a simplified model (a table of 4 buckets, bucket = hashCode mod 4); real Dart tables are bigger and resize, but the failure is the same.

Copying such a corrupted set with Set.of(s) or s.toSet() may clone the old table and keep the stale bucket. Rebuild it through a list instead (Set.of(s.toList())), or better, never mutate keys.

9. Strings are immutable: StringBuffer

A String is a sentence engraved on a stone tablet. To "add a word" you must engrave a whole new tablet (copying the old words). A StringBuffer is a notepad: you keep writing, and only at the end do you engrave ONE tablet with toString().

Because strings are immutable, every s += w in a loop copies everything built so far: for n pieces that is about 1 + 2 + ... + n pieces of copying, so Θ(n²). A StringBuffer collects the pieces and builds one final string. (This describes the Dart VM; on the web, JavaScript engines may optimise += internally, but writing to a buffer is the habit that is fast everywhere.) Type your own words (1-5 words, 1-6 letters or digits each, separated by commas).

All other String methods (toUpperCase, replaceAll, trim, substring, +, *) return a new string and never modify the receiver; see the member cards below.

10. Mutability and isolates

Two offices in different buildings. When one office mails a folder to the other, it sends a photocopy. If the other office scribbles on its copy, the first office's folder is unchanged. That is why isolates (Dart's independent workers) need no locks.

Each isolate has its own heap. A message you send, and any variable a closure passed to Isolate.run captures, is copied into the receiver's heap, so mutable objects are never shared between isolates. Static and top-level variables are also separate per isolate. (Isolate.exit and TransferableTypedData can hand data over without a full copy, and deeply immutable data may be shared by the VM invisibly.)

The cost of that safety is the copy: O(size of the data) in and O(size of the result) out. Passing many small immutable pieces is cheap; passing one huge list to a short task can cost more than the task.

11. Changing a collection while looping

An iterator remembers how many times the collection was modified when the loop started. If the collection changes structurally (length, keys) during the loop, the next step throws ConcurrentModificationError. The safe fix is to loop over a copy, or to collect changes and apply them after.

Fixed-length lists sit between mutable and immutable: the slots are fixed (no add/remove/length change) but the content is still writable.

Four levels of list: growable (everything allowed) → fixed-length (set elements only) → unmodifiable (read only, runtime-enforced) → const (read only, one shared compile-time object).

12. Every member: does it mutate the receiver or return something new?

Every member this lesson relies on, grouped by purpose. The badge says what happens to the receiver: mutates changes the object you called it on, new copy/new returns a different object, view returns a live window onto the same data, read-only changes nothing. The examples were run with Dart 3.11 and their output is exactly what is shown. (The complete List/Set/Map member lists are the subject of D26 and D27.)

13. Choosing the right tool

I need ...UseSees later changes of the source?CostCan the receiver mutate?
a normal working list[...] / List.ofn/a (own object)O(n) to copyyes
a private snapshot nobody can editList.unmodifiable(x)noO(n)no (UnsupportedError)
to expose an internal list read-onlyUnmodifiableListView(x)yesO(1)no, but the owner can
a constant table known at compile timeconst [...]never changes0 at run time (shared)no
a fixed number of slotsList.filled(n, v) / growable: falsen/aO(n)elements yes, length no
to build a long stringStringBuffern/aamortised O(1) per writebuffer yes, final string no
a value type used as a Map keyimmutable class with final fields, ==, hashCoden/aO(fields) per compare/hashno
to give an isolate datasend it (copied)no, separate heapsO(size)only its own copy

Quiz

Interview questions

Cheat sheet

Rules of thumb: assignment copies the arrow; final fixes the variable; const fixes the object; a copy constructor is shallow; make fields final and copy at the doors; never mutate what feeds hashCode; each isolate gets copies.

Mutator on a plain listWhat an unmodifiable list does
add addAll insert insertAll remove removeAt removeLast removeWhere retainWhere removeRange clear setAll setRange fillRange replaceRange sort shuffle []= length= first= last= (21)all throw UnsupportedError
Map: []= addAll addEntries clear remove removeWhere putIfAbsent update updateAll (9)all throw UnsupportedError
Set: add addAll remove removeAll retainAll removeWhere retainWhere clear (8)all throw UnsupportedError
MemberReceiverTime (why)Can throw