skip to content

In Dart, why can code still call add() on a list held in a final variable, and what actually makes a list unmodifiable?

level: juniorimportance: must knowfreq 62%

answer

  1. variable versus object
  2. final blocks reassignment only
  3. const literal, List.unmodifiable, a view
  4. static type is still List<E>
  5. UnsupportedError at runtime

basics

~20 s

final 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 lines
dart
import '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

for a junior

Recall that final stops reassignment, not mutation, and name the three unmodifiable options: a const literal, List.unmodifiable and UnmodifiableListView.

for a middle

Explain why the failure is a runtime UnsupportedError, which operations count as writes, and why unmodifiable is shallow unless the elements are immutable.

for a senior

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.

for a principal

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