In Dart, why can code still call add() on a list held in a final variable, and what actually makes a list unmodifiable?
answer
- variable versus object
- final blocks reassignment only
- const literal, List.unmodifiable, a view
- static type is still List<E>
- UnsupportedError at runtime
basics
~20 sfinal only stops the variable from being reassigned; the list object it points to stays growable. A list rejects writes only when the object itself is unmodifiable: a const literal, a List.unmodifiable copy or an UnmodifiableListView, and then writes throw UnsupportedError at runtime.
solid answer
~40 s`final` is a property of the **variable**: it can be assigned once. The **object** it refers to is a separate matter, so `final tags = ['a']; tags.add('b');` works and the list becomes `['a', 'b']`. To stop mutation you need an unmodifiable list object: a `const` literal, a copy made with `List.unmodifiable` (or `List.unmodifiableOf` since Dart 3.13), or a `dart:collection` `UnmodifiableListView` wrapped around another list. All of these still have the static type `List<E>`, so the analyzer accepts `add`, `[]=`, `sort` or `clear`; the failure is an `UnsupportedError` at runtime. And unmodifiable is shallow unless the elements are themselves immutable: a `const` list is deeply constant, the other two are not.
code
dart · 18 linesimport 'dart:collection';
void main() {
final growable = ['sale'];
growable.add('new'); // OK: final protects the variable only
final frozen = List.unmodifiable(growable);
final view = UnmodifiableListView(growable);
const fixed = ['sale', 'new'];
for (final list in [frozen, view, fixed]) {
try {
list.add('x');
} on UnsupportedError catch (e) {
print('rejected: ${e.message}');
}
}
}go deeper
Recall that final stops reassignment, not mutation, and name the three unmodifiable options: a const literal, List.unmodifiable and UnmodifiableListView.
Explain why the failure is a runtime UnsupportedError, which operations count as writes, and why unmodifiable is shallow unless the elements are immutable.
Choose between const, a copy and a view per API, and catch code that relies on final or an Iterable return type to protect internal state.
Set codebase conventions for exposing collections, such as unmodifiable views from models and immutable element types, so mutation bugs cannot cross module boundaries.
## Two separate questions: the variable and the object Every Dart variable that holds a collection raises two independent questions: 1. **Can the variable be pointed at a different list?** That is what `final`, `const` and `var` decide. 2. **Can the list object itself change?** That is decided by which kind of object you created. `final` answers only the first question. A `final` variable is assigned exactly once, but the `List` it refers to is an ordinary growable list unless you created something else: ```dart final tags = ['sale']; tags.add('new'); // fine: the list object is growable // tags = ['other']; // compile error: tags is final print(tags); // [sale, new] ``` This is the root of a common interview slip: "I made the field `final`, so callers can't change the cart." They can; they just cannot replace it. ## The ways to get an unmodifiable list object Dart gives you three kinds of object that reject writes: - **A `const` literal**: `const ['sale', 'new']`. It is a compile-time constant; every element must itself be a constant, so the whole value is immutable all the way down. - **An unmodifiable copy**: `List.unmodifiable(source)`, or `List.unmodifiableOf(source)` in Dart 3.13 and later. It copies the elements once into a new list that cannot change length or contents. - **An unmodifiable view**: `UnmodifiableListView(source)` from `dart:collection`. It copies nothing; it forwards reads to `source` and throws on writes. `Set.unmodifiable`, `Map.unmodifiable` (and `Map.unmodifiableOf` since 3.13), `UnmodifiableSetView` and `UnmodifiableMapView` do the same for the other collection types. | Kind | Reassign variable? | `add` on the list? | Elements immutable? | |---|---|---|---| | `final xs = [1]` | no | yes | only if they are | | `final xs = const [1]` | no | throws | yes, all constants | | `List.unmodifiable(src)` | depends on the variable | throws | only if they are | | `UnmodifiableListView(src)` | depends on the variable | throws | only if they are | ## Why the error comes at runtime All of these objects still implement `List<E>`, so their **static type** has `add`, `remove`, `[]=`, `sort` and `clear`. The analyzer has no way to know a particular `List` rejects them. Each mutating method is overridden to throw an **`UnsupportedError`**, with messages such as "Cannot modify an unmodifiable list". Two consequences follow: - a bug shows up only when that code path runs, often far from where the list was created; - in-place operations you might not think of as writes, like `sort()` or `shuffle()`, also throw. If you want the compiler to help, expose a narrower type, such as `Iterable<E>`, in addition to an unmodifiable object; a narrower static type on its own can still be downcast back to `List` and mutated. ## Shallow versus deep `List.unmodifiable` and `UnmodifiableListView` protect the **list**, not the elements. The SDK's own documentation for `List.unmodifiable` says the list is immutable only "if the elements are themselves immutable". A list of `CartItem` objects with a mutable `quantity` field can still have every quantity changed. A `const` list is different: its elements must be constants, so nothing reachable from it can change. ## The same rules for sets, maps and typed data Everything above carries over to the other collection types: - `Set.unmodifiable(elements)` and `Map.unmodifiable(other)` make frozen copies; Dart 3.13 added `Map.unmodifiableOf`, whose parameter is typed `Map<K, V>`. - `UnmodifiableSetView` and `UnmodifiableMapView` in `dart:collection` are the read-through wrappers. - `const {}` and `const <String>{}` are constant, unmodifiable maps and sets. - Typed data lists such as `Uint8List` use `asUnmodifiableView()` (added in Dart 3.3); the old `UnmodifiableUint8ListView`-style constructors were deprecated and then removed. In every case the static type still offers the mutating methods, so the error is again a runtime `UnsupportedError`. ## What to say in an interview - `final` protects the variable; the object needs its own protection. - Pick `const` for fixed data known at compile time, an unmodifiable copy for a snapshot, and a view for a live read-only window. - Expect runtime `UnsupportedError`, not compile errors. - Unmodifiable is shallow unless the elements are immutable too.
- Does calling sort() on a List.unmodifiable result work in Dart?No. `sort` rearranges the list in place, so it is a write and throws `UnsupportedError`, as do `shuffle`, `clear` and `[]=`. To get a sorted version, copy first: `[...frozen]..sort()` or `List.of(frozen)..sort()`, which gives a new growable list.
- Is a const list in Dart deeply immutable, unlike List.unmodifiable?Yes. Every element of a `const` collection must itself be a compile-time constant, and constant objects cannot change, so nothing reachable from it is mutable. `List.unmodifiable` only freezes the list's length and slots; mutable elements inside it can still be changed.
- Why not just return Iterable<CartItem> to stop callers modifying a list?A narrower static type hides `add` from the analyzer, but the runtime object is still the growable list, so `(cart.items as List<CartItem>).add(x)` succeeds. Returning an unmodifiable object is what actually prevents the write; the narrower type is optional documentation on top.
saying these in an interview costs you the question
- A final list cannot have elements added or removed
- Calling add on an unmodifiable list is a compile-time error
- List.unmodifiable makes the elements themselves immutable
- Returning the list as Iterable is enough to stop callers mutating it
- sort() is allowed on an unmodifiable list because it adds nothing