Covariance and Invariance
Arrays Are Covariant
In Java, if Dog is a subtype of Animal, then Dog[] is treated as a subtype of Animal[]:
Dog[] dogs = { new Dog(), new Dog() };
Animal[] animals = dogs; // compiles — Dog[] is an Animal[]
This is called covariance: the array type varies in the same direction as its element type.
The Array Covariance Trap
Covariance is convenient but unsound:
Animal[] animals = new Dog[3];
animals[0] = new Cat(); // compiles (Cat is an Animal) but throws ArrayStoreException at RUNTIME
The compiler allows this because Cat is an Animal — the static type of animals[0] is Animal, and assigning a Cat to an Animal variable is legal. But at runtime, the JVM knows that animals actually references a Dog[], and storing a Cat into a Dog[] is illegal. The JVM throws ArrayStoreException.
This works because Java arrays are reified — they remember their component type at runtime. Every array store includes a runtime check of the component type. The check catches the error, but only at runtime, not at compile time.
Generics Are Invariant
Generics take the opposite approach: List<Dog> is not a subtype of List<Animal>, even though Dog is a subtype of Animal:
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // COMPILE ERROR — incompatible types
This is called invariance. The generic type does not vary with its type parameter at all.
Why Invariance Is Safer
If generics were covariant, we would have the same unsoundness that arrays suffer from:
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // hypothetical — if generics were covariant
animals.add(new Cat()); // adding Cat to what is actually List<Dog>
Dog d = dogs.get(0); // ClassCastException — the element is actually Cat
By making generics invariant, the compiler catches the error at the first line — List<Animal> animals = dogs simply does not compile. There is no way to sneak a Cat into a List<Dog> through an Animal-typed variable.
Why Two Different Design Choices?
Arrays and generics were designed at different times with different goals:
| Aspect | Arrays | Generics |
|---|---|---|
| Introduced | Java 1.0 (1995) | Java 5 (2004) |
| Design priority | Flexibility | Type safety |
| Runtime representation | Reified — component type known at runtime | Erased — type parameter absent at runtime |
| Variance | Covariant (with runtime checks) | Invariant (with wildcards for controlled variance) |
| Type errors caught | At runtime (ArrayStoreException) | At compile time |
The Java designers have acknowledged that array covariance was a mistake that they could not fix without breaking backward compatibility. Generics were the opportunity to do it right.
The Trade-Off
Array covariance lets you write flexible code that operates on arrays of any subtype:
void printAnimals(Animal[] animals) {
for (Animal a : animals) {
System.out.println(a.sound());
}
}
printAnimals(new Dog[3]); // works — covariance
But the flexibility comes at the cost of deferred type checking — the ArrayStoreException lurking at runtime.
Generic invariance forces you to be explicit about variance through wildcards, which restore flexibility safely:
void printAnimals(List<? extends Animal> animals) {
for (Animal a : animals) {
System.out.println(a.sound());
}
}
printAnimals(new ArrayList<Dog>()); // works — wildcard accepts subtypes
The wildcard approach is more verbose but gives the compiler enough information to prevent improper stores — animals.add(new Cat()) would be rejected because the compiler knows the list could be List<Dog> or List<Cat> and adding a Cat would be unsafe.
Practical Rule
- Use arrays when the element type is known and fixed, and you accept the runtime-check trade-off.
- Use generic collections by default for type safety.
- When you need to accept a range of generic types in a method, use wildcards (
? extends Tfor reading,? super Tfor writing) rather than raw types or unchecked casts.