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?
answer
- subtyping follows the type argument
- covariant by default
- the object stays a List<Cat>
- checks on add, insert and []=
- TypeError at the write, not the call
basics
~10 sDart 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 sDart 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 linesclass 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
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.
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.
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.
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