Java Generic Invariance: Type Safety in Practice
java generic invariance: Understand why Java generics are invariant, how this differs from arrays, and how wildcards provide flexibility without breaking type safety.
In Java, generic types are invariant. That means List<String> is not a subtype of List<Object>, even though String is a subtype of Object. This rule is not a quirk; it is a deliberate design decision that prevents a class of type-safety bugs that would otherwise be possible. Understanding java generic invariance is essential for writing correct generic APIs and for reasoning about why certain assignments fail to compile.
The Core Rule: Generic Types Are Invariant
The simplest way to see invariance is to try an assignment that seems natural at first glance:
List<String> strings = new ArrayList<>(); List<Object> objects = strings; // compile error
The second line fails because List<String> and List<Object> are unrelated types in the generic type system. Even though String is an Object, the generic type List does not preserve that subtype relationship. This is invariance: the generic type parameter must match exactly for the types to be compatible.
The same rule applies to method arguments. If a method accepts List<Object>, you cannot pass a List<String> to it:
void printAll(List<Object> items) { for (Object item : items) { System.out.println(item); } } List<String> names = List.of("Alice", "Bob"); printAll(names); // compile error
This restriction often surprises developers who are used to polymorphism in non-generic contexts. The reason becomes clear when you consider what could go wrong if invariance were not enforced.
Why Invariance Exists: Preventing Unsafe Assignments
Imagine that List<String> were allowed to be assigned to List<Object>. Then you could add an Integer to that list through the List<Object> reference:
List<String> strings = new ArrayList<>(); List<Object> objects = strings; // hypothetical: allowed if covariant objects.add(42); // would be allowed String first = strings.get(0); // ClassCastException at runtime
Because objects is a view of the same underlying list, adding an Integer would corrupt the list from the perspective of the strings reference. The type system would be unable to guarantee that strings only contains String instances. Invariance closes this hole by making the assignment illegal in the first place.
This is a fundamental difference from arrays. Arrays are covariant in Java, which leads to a different set of tradeoffs.
Arrays Are Covariant: The Contrast That Causes Confusion
Arrays have been covariant since the first version of Java. You can write:
String[] strings = new String[10]; Object[] objects = strings; // allowed
This compiles because String[] is considered a subtype of Object[]. The consequence is that you can also do this:
objects[0] = 42; // ArrayStoreException at runtime
The assignment to objects[0] is allowed by the compiler, but the array runtime checks the actual type of the element being stored and throws ArrayStoreException if the type is incompatible. Arrays enforce their element type at runtime, so the covariance does not lead to heap pollution.
Generics, on the other hand, are erased at runtime. The JVM does not know that a List<String> is supposed to contain only strings. That information exists only at compile time. If generics were covariant, there would be no runtime check to catch the invalid insertion, and the failure would surface later as a ClassCastException at an unpredictable location. Invariance moves the error to compile time, where it belongs.
Using Wildcards to Recover Flexibility
Invariance can feel restrictive when you want to write a method that accepts a collection of some specific type. Wildcards give you a safe way to relax the type constraint without losing type safety.
The upper-bounded wildcard ? extends T allows you to read from a collection but not write to it:
void printNumbers(List<? extends Number> numbers) { for (Number n : numbers) { System.out.println(n); } } List<Integer> integers = List.of(1, 2, 3); printNumbers(integers); // works
The method can accept List<Integer>, List<Double>, or any List whose element type is a subtype of Number. Inside the method, you can only read elements as Number; you cannot add to the list because the actual element type is unknown.
The lower-bounded wildcard ? super T is useful when you want to write to a collection:
void addNumber(List<? super Integer> numbers) { numbers.add(42); } List<Number> numbers = new ArrayList<>(); addNumber(numbers); // works
Here, the list can hold Integer or any supertype of Integer, so adding an Integer is safe. The tradeoff is that reading from such a list yields Object, because the exact element type is unknown.
The mnemonic PECS (Producer Extends, Consumer Super) captures the rule: use extends when the collection produces values for you to read, and super when it consumes values you write.
Common Pitfalls with Invariant Generic Parameters
A frequent mistake is trying to pass a List<String> to a method that expects List<Object> without using a wildcard. The fix is either to change the method signature to use List<?> or List<? extends Object> (which is effectively the same as List<?>) or to copy the list into a new List<Object>.
Another pitfall is assuming that a generic class with multiple type parameters is invariant in all of them. For example, Map<String, Integer> is not a subtype of Map<Object, Object>. Each type parameter must match exactly unless wildcards are used.
A more subtle issue arises with generic methods. Consider:
static <T> void copy(List<? extends T> src, List<? super T> dest) { for (T item : src) { dest.add(item); } }
This method works because src is a producer and dest is a consumer. If you tried to use List<T> for both parameters, you would lose the ability to copy from a List<Integer> into a List<Number> without first converting the list type. Wildcards make the method flexible while preserving type safety.
Designing APIs with Invariance in Mind
Invariance forces you to think about the direction of data flow when designing method signatures. If a method only reads from a collection, declare the parameter with ? extends. If it only writes, use ? super. If it does both, you often need to use the exact type T or accept that the method is limited to a single concrete type.
This design discipline improves maintainability because it makes the contract explicit. A reader of the method signature immediately knows whether the method is allowed to modify the collection. It also prevents accidental modifications that could break the caller's assumptions.
When you are building a library, invariance also affects how you expose generic types. For example, a method that returns List<String> cannot be assigned to a List<Object> variable without a wildcard or a copy. This can be surprising to consumers of your API. Documenting the intended usage and providing overloads that use wildcards when appropriate reduces friction.
Runtime Erasure: Why Invariance Is a Compile-Time Rule
Generics are a compile-time feature in Java. The compiler checks all generic type relationships and then erases type parameters to their bounds or to Object in the bytecode. At runtime, a List<String> and a List<Integer> are both just ArrayList instances with no knowledge of their element type.
This is why invariance cannot be enforced at runtime. There is no instanceof check for generic types, and casting to List<String> produces an unchecked warning. The compiler relies on the invariant rule to guarantee that the code it produces is type-safe, assuming no unchecked casts are used improperly.
Understanding this erasure also explains why you cannot create generic arrays. new T[] is illegal because the runtime would not be able to enforce the component type. Arrays need reifiable types, while generics do not. The combination of covariant arrays and erased generics is why mixing them often leads to warnings or errors. For example, List<String>[] is not allowed, but List<?>[] is, because the wildcard is reifiable.
In practice, you should avoid mixing arrays and generics unless you are certain about the runtime type. If you need a collection of collections, prefer List<List<T>> over an array of lists. This keeps the type system consistent and avoids unchecked casts.