skip to content

In Dart, why does passing a List<Cat> where a List<Animal> is expected compile, and what happens when the callee then adds a Dog?

level: middleimportance: should knowfreq 42%

answer

  1. subtyping follows the type argument
  2. covariant by default
  3. the object stays a List<Cat>
  4. checks on add, insert and []=
  5. TypeError at the write, not the call

basics

~10 s

Dart generic classes are covariant, so List<Cat> is a subtype of List<Animal> and the call compiles. The list keeps its reified List<Cat> type, so add(Dog()) fails a runtime parameter check and throws a TypeError.

solid answer

~40 s

Dart makes generic class types covariant in their type arguments: because `Cat` is a subtype of `Animal`, `List<Cat>` is a subtype of `List<Animal>`, so the call type-checks. That is only safe for reading. The object is still a `List<Cat>` at run time, because Dart reifies type arguments, so members whose parameters take an element of type `E` (`add`, `insert`, `addAll`, `operator []=`, even `indexOf`) check the argument at run time. `add(Dog())` fails that check and throws a `TypeError` inside the callee, at the write rather than at the call. The heap never holds a `Dog` in a `List<Cat>`, which is how Dart stays sound. The fix is design: read through an `Iterable<Animal>` and return a fresh `List<Animal>`, or have the caller create the list as `<Animal>[]`.

code

dart · 18 lines
dart
class Animal {}

class Cat extends Animal {}

class Dog extends Animal {}

// Unsafe: mutates a list whose real element type it does not know.
void adoptStray(List<Animal> shelter) => shelter.add(Dog());

// Safe: reads through a producer type, returns a list it created.
List<Animal> withStray(Iterable<Animal> shelter) => [...shelter, Dog()];

void main() {
  final cats = <Cat>[Cat()];
  final mixed = withStray(cats); // List<Animal> with a Cat and a Dog
  print(mixed.length); // 2
  adoptStray(cats); // throws TypeError inside adoptStray, at add
}

go deeper

for a junior

Remember that Dart lets a List<Cat> go where a List<Animal> is expected, and that adding the wrong kind of animal through that wider view throws at run time.

for a middle

Explain covariant-by-class parameters: the list's type argument is reified, so add, insert, []= and indexOf check their argument against it, while reads and Object?-typed members do not.

for a senior

Show that you design APIs around it: accept Iterable when you only read, return new lists instead of mutating arguments, and know the stack trace points into the callee, not the caller.

for a principal

Weigh Dart's choice of convenient covariance plus runtime checks against statically checked variance, and set team conventions, such as the experimental unsafe_variance lint, that keep the risky shapes out of shared APIs.

## Covariant by default In Dart, a generic **class** type is **covariant** in its type arguments: if `Cat` is a subtype of `Animal`, then `List<Cat>` is a subtype of `List<Animal>`, `Map<String, Cat>` is a subtype of `Map<String, Animal>`, and so on. Nothing needs to be declared for this — it is how every Dart generic class behaves, including your own. That is why this compiles: ```dart class Animal {} class Cat extends Animal {} class Dog extends Animal {} void adoptStray(List<Animal> shelter) { shelter.add(Dog()); // statically fine: a Dog is an Animal } void main() { final cats = <Cat>[Cat()]; adoptStray(cats); // compiles: List<Cat> is a List<Animal> } ``` The general theory of why a mutable container cannot be safely covariant belongs to the generic-programming concept pages. What is specific to Dart is **how the language pays for this choice**. ## Where the runtime check lives Dart **reifies** type arguments: the list created by `<Cat>[]` knows at run time that its element type is `Cat`, and that never changes. Because a caller may see it through a wider static type, Dart treats parameters that accept an element of the class's type parameter as **covariant-by-class** and checks the actual argument against the object's real type argument when the member is called. | Member of `List<E>` | Parameter type | Checked when seen as a wider type? | |---|---|---| | `add`, `insert`, `operator []=` | `E` | Yes — a `Dog` into a `List<Cat>` throws | | `addAll` | `Iterable<E>` | Yes | | `indexOf` | `E` | Yes — a lookup that only reads still throws | | `contains`, `remove` | `Object?` | No — they just return `false` | | `first`, `operator []`, iteration | (returns `E`) | No — every `Cat` read out is a valid `Animal` | So in the example, `adoptStray` throws a **`TypeError`** on the `add` line, not at the call site in `main`. The stack trace points into the callee, which is often far from the code that created the list — the main reason this bug takes time to find. ## Why the program stays sound Dart's type system is **sound**: a variable of static type `T` never holds a value that is not a `T`. Covariant generics would break that if the `Dog` were stored, because code holding `cats` as a `List<Cat>` would later read a `Dog`. The runtime check prevents exactly that: the write is rejected before it happens. The trade-off is that an error the compiler could have reported moves to run time, and every such call carries a check the compiler cannot always remove. One caveat for web builds: Flutter's JavaScript release build passes `-O4` to dart2js by default, a level above the sound-only `-O2`, so treat the `TypeError` as a bug detector during development and testing rather than as behaviour to rely on in production. ## Designing around it Dart has no way to say "this parameter only writes" or "only reads" at the use site, so the fixes are about API shape: 1. **Read through a producer type.** If the routine only reads, take `Iterable<Animal>` — it has no `add`, so there is nothing unsafe to call. 2. **Return a new list instead of mutating the argument.** `List<Animal> withDog(Iterable<Animal> pets) => [...pets, Dog()];` builds a fresh `List<Animal>` from the return type's context. 3. **Let the owner choose the element type.** If the caller must pass a list that will receive dogs, it should create it as `<Animal>[]`. 4. **Copy when you must mutate.** `List<Animal>.of(cats)` makes a list whose reified type is `List<Animal>`. Things that do **not** help: - Widening the parameter to `List<Object>` — a `List<Cat>` is a `List<Object>` too, and the same check fires. - A generic signature such as `void addDog<T extends Animal>(List<T> pets)` — inside it `pets.add(Dog())` does not compile, because a `Dog` is not a `T`. ## What Dart does not have yet Statically checked variance (declaration-site `in`/`out`/`inout` modifiers) has existed only as an experiment behind a flag; it is not part of the Dart 3.13 language. The analyzer's experimental `unsafe_variance` lint (added in Dart 3.7) flags members that place a class type parameter in a non-covariant position, such as a field of type `bool Function(X)`, which is the related case where even a **read** can throw.

  • Why can `animals.indexOf(Dog())` throw when `animals` is really a `List<Cat>`, although it only searches?
    `List.indexOf` declares its parameter as `E`, not `Object?`, so it is a covariant-by-class parameter and gets the same runtime check as `add`. A `Dog` is not a `Cat`, so the call throws a `TypeError` before searching. `contains` and `remove` take `Object?`, so they skip the check and simply return `false`.
  • Does `List<Animal>.of(cats)` or `cats.cast<Animal>()` give you a list you can add a Dog to?
    `List<Animal>.of(cats)` does: it copies into a new list whose reified type is `List<Animal>`. `cats.cast<Animal>()` does not: it is a view over the original list, and elements added through it must still be accepted by the underlying `List<Cat>`, so adding a `Dog` still throws.

saying these in an interview costs you the question

  • Says List<Cat> is not assignable to List<Animal> in Dart
  • Claims the TypeError happens at the call site, not at the write
  • Believes the list widens to List<Animal> once a Dog is added
  • Thinks type arguments are erased so the Dog is silently stored
  • Proposes widening the parameter to List<Object> as the fix