skip to content

In Dart, why should a class copy a List it receives in its constructor, and how do List.of, List.unmodifiable and List.unmodifiableOf differ?

level: middleimportance: should knowfreq 36%

answer

  1. the caller still holds a reference
  2. aliasing breaks invariants later
  3. List.of: typed, growable copy
  4. unmodifiable takes an untyped Iterable
  5. unmodifiableOf is typed, Dart 3.13

basics

~20 s

Storing the caller's list means the caller can still change it later, silently changing the object; copying in the constructor breaks that link. List.of makes a typed growable copy, List.unmodifiable a frozen one, and Dart 3.13's List.unmodifiableOf a frozen one with a statically typed argument.

solid answer

~40 s

If a constructor stores `items` directly, the object and the caller **share** one list, so a later `callerList.clear()` changes the object behind its back and can break invariants validated in the constructor. A **defensive copy** breaks the aliasing: `_items = List.of(items)` gives a private growable copy typed by `Iterable<E>`; `List.unmodifiable(items)` gives a frozen snapshot. `List.unmodifiable` takes an untyped `Iterable`, so its element type comes from context and a wrong element fails only at runtime. Dart 3.13 added `List.unmodifiableOf(Iterable<E>)` (and `Map.unmodifiableOf`) with the element type checked statically; the SDK source already carries a commented-out deprecation on `List.unmodifiable` pointing to it. Note that freezed's unmodifiable collections are views over the list passed in, not copies.

code

dart · 16 lines
dart
class CartItem {
  const CartItem(this.name);
  final String name;
}

class Order {
  Order(List<CartItem> items) : items = List.unmodifiableOf(items);
  final List<CartItem> items;
}

void main() {
  final picked = [const CartItem('tea')];
  final order = Order(picked);
  picked.clear();
  print(order.items.length); // 1: the order owns its copy
}

go deeper

for a junior

Recall that storing a caller's list shares it, and that List.of or List.unmodifiable in the constructor makes the object's own copy.

for a middle

Explain aliasing with an invariant that breaks, compare List.of, List.from, List.unmodifiable and unmodifiableOf, and state that copies are shallow.

for a senior

Spot aliasing in models and generated classes, know freezed wraps rather than copies, and adopt unmodifiableOf where the SDK constraint allows 3.13.

for a principal

Decide where copy boundaries sit, such as public model constructors versus internal helpers, trading allocation cost for guarantees the rest of the team can rely on.

## The aliasing problem When a constructor stores a list it was given, the new object and the caller hold **the same list object**: ```dart class Order { Order(this.items) : assert(items.isNotEmpty); final List<CartItem> items; } final picked = [CartItem('tea')]; final order = Order(picked); picked.clear(); // the caller cleans up its own list print(order.items); // [] : the order is now empty ``` The `assert` checked the list once, at construction. Nothing checks it again, so the caller's later `clear()` breaks an invariant the class believed it had. This is **aliasing**: two names for one mutable object. `final` on the field does not help, because it protects the field, not the list. ## The fix: copy at the boundary A **defensive copy** makes the object own its data: 1. copy the incoming list in the constructor (or initializer list); 2. validate the copy, not the original; 3. expose it read-only if callers must not change it either. ```dart class Order { Order(List<CartItem> items) : items = List.unmodifiableOf(items) { if (this.items.isEmpty) throw ArgumentError('empty order'); } final List<CartItem> items; } ``` Now `picked.clear()` has no effect on the order, and nobody can call `order.items.add` either. ## Which copy constructor | Constructor | Result | Argument type | Since | |---|---|---|---| | `List.of(items)` | growable copy (`growable: false` for fixed-length) | `Iterable<E>` | long-standing | | `List.from(items)` | growable copy | `Iterable<Object?>` | long-standing | | `List.unmodifiable(items)` | unmodifiable copy | untyped `Iterable` | long-standing | | `List.unmodifiableOf(items)` | unmodifiable copy | `Iterable<E>` | Dart 3.13 | The typing column matters: - **`List.of`** infers the element type from its argument and rejects a mismatched iterable at compile time. - **`List.from`** accepts any iterable, which is why its documentation shows it being used to **downcast** a `List<dynamic>`; a wrong element fails at runtime. - **`List.unmodifiable`** has the same loose argument. Without a context type, `var xs = List.unmodifiable(items)` can end up as `List<dynamic>`. - **`List.unmodifiableOf`**, added in Dart 3.13 "with better typing than `List.unmodifiable`", takes `Iterable<E>`, so `E` is inferred from the argument and mismatches are caught statically. `Map.unmodifiableOf` does the same for maps. On Dart 3.13 and later, prefer `unmodifiableOf` for new code. Packages that support older SDKs must keep `List.unmodifiable` and give it a type argument, as in `List<CartItem>.unmodifiable(items)`. ## Copies are shallow A defensive copy duplicates the **list**, not the elements. If `CartItem` is mutable, the caller can still change an item it kept a reference to. A complete defence copies or rebuilds mutable elements too, or, more simply, makes the element type immutable. ## Code generators and views freezed 4 wraps `List`, `Set` and `Map` fields in `EqualUnmodifiableListView`, `EqualUnmodifiableSetView` and `EqualUnmodifiableMapView` by default (`makeCollectionsUnmodifiable`). The generated getter wraps the list that was passed to the constructor; it does **not** copy it. Callers therefore cannot mutate the field through the object, but a caller that keeps its own reference to the original list and mutates it still changes the freezed value. Pass a list you no longer touch, or copy before constructing. ## Testing for aliasing Two small tests catch the bug before it ships: 1. construct the object from a list, then mutate the **original** list, and assert the object is unchanged; 2. read the collection from the object, try to mutate it, and assert either that it throws `UnsupportedError` (for an unmodifiable copy or view) or that the object is unchanged (for a returned copy). These tests document the ownership contract as well as checking it, which matters when the class is later refactored or regenerated. ## When not to copy - A private helper that owns both sides of the call does not need one. - Very hot paths with large lists may prefer a documented "caller must not mutate" contract, but that is a trade-off, not a default.

  • Why does var xs = List.unmodifiable(items) risk a List<dynamic> in Dart?
    `List.unmodifiable` declares its parameter as a plain `Iterable`, so the argument does not fix the element type. With no context type, inference can fall back to `dynamic`. Give it context (`List<CartItem> xs = ...`), a type argument (`List<CartItem>.unmodifiable(items)`), or use `List.unmodifiableOf`, which takes `Iterable<E>`.
  • Does freezed's makeCollectionsUnmodifiable protect a model from the caller's original list?
    No. The generated getter wraps the stored constructor argument in an `EqualUnmodifiableListView`, so writes through the model throw, but the view reads the caller's list. If the caller keeps and mutates that list, the model's contents change. Copy before constructing if the caller keeps the list.

saying these in an interview costs you the question

  • Marking the field final is enough to protect it from the caller's list
  • List.unmodifiable checks element types at compile time like List.of
  • A defensive copy also protects the mutable objects inside the list
  • freezed copies list fields so the caller's original list cannot affect it
  • List.unmodifiableOf has been available since Dart 2