Why is List<String> not a List<Object>?

Asked constantlyintermediate2–10 yrs11 min readJava 5 (1.5)Java 8LTSJava 10

Because generics are invariant, and that is deliberate. Arrays are covariant — String[] really is an Object[] — and Java pays for it with a runtime ArrayStoreException on every array store. Generics chose the compile-time answer instead, and wildcards give the flexibility back where it is safe: extends to read, super to write.

The Answer

  • Generics are invariant. List<String> is not a List<Object>, not a subtype, not assignable. Neither is List<Object> a List<String>.
  • The reason is soundness. If it were allowed, you could put a Dog into a List<Cat> through the wider reference, and nothing would catch it until a read somewhere else.
  • Arrays are covariantString[] is an Object[] — which is a Java 1.0 decision that predates generics. Java pays for it with a runtime check on every array store, and ArrayStoreException when it fails.
  • So the two rules are not an inconsistency. Arrays chose runtime detection; generics chose compile-time prevention, which is the better trade.
  • Wildcards hand the flexibility back where it is safe: PECS — Producer extends, Consumer super.
  • ? extends T gives you a list you can read T from and cannot add to. ? super T gives you one you can add T to and can only read Object from.

Understand It

The array version of this bug, still live in the language

The clearest way to see why generics are invariant is to watch the alternative fail. Arrays do allow it:

Compiled and run on this buildEdit and run
String[] names = {"ravi", "anita"};
Object[] asObjects = names;                  // legal: arrays are covariant

System.out.println("Object[] x = String[] compiled : true");
System.out.println("asObjects.length               : " + asObjects.length);

try {
    asObjects[0] = 42;                       // compiles. does not run.
} catch (ArrayStoreException e) {
    System.out.println("storing an Integer             : ArrayStoreException: " + e.getMessage());
}
Output
Object[] x = String[] compiled : true
asObjects.length               : 2
storing an Integer             : ArrayStoreException: java.lang.Integer

asObjects[0] = 42 is a perfectly typed statement — you are putting an Object into an Object[]. It compiles. It fails at runtime, because the array object knows it is really a String[] and checks every single store against its actual component type.

That check is not free, and it is not optional. Every array store in every Java program pays for a decision made in 1995.

Now the same shape with generics:

List<String> names = new ArrayList<>();
List<Object> asObjects = names;        // error: incompatible types
error: incompatible types: ArrayList<String> cannot be converted to List<Object>

The bug is gone because the assignment is gone. That is the whole argument for invariance: the error moved from a runtime exception at an arbitrary later line to a compile error on the line that is actually wrong.

Wildcards, and the rule that makes them memorable

Invariance is safe and inflexible. A method that sums numbers should not have to be written three times for List<Integer>, List<Double> and List<Long>. Wildcards are how you get one method back.

Compiled and run on this buildEdit and run
System.out.println("sum of List<Integer> : " + sumOf(List.of(1, 2, 3)));
System.out.println("sum of List<Double>  : " + sumOf(List.of(1.5, 2.5)));
System.out.println("sum of List<Long>    : " + sumOf(List.of(10L, 20L)));

List<Number> mixed = new ArrayList<>(List.of(0.5));
addIntegers(mixed, 2);
System.out.println("after addIntegers    : " + mixed);
Output
sum of List<Integer> : 6.0
sum of List<Double>  : 4.0
sum of List<Long>    : 30.0
after addIntegers    : [0.5, 1, 2]

sumOf takes List<? extends Number> and addIntegers takes List<? super Integer>. The mnemonic is PECSProducer extends, Consumer super — and it is worth restating as a question you can actually answer at a keyboard: does this method read from the collection, or write to it?

  • Reads from it → it is a producer → ? extends T.
  • Writes to it → it is a consumer → ? super T.
  • Both → no wildcard. Use plain List<T>, and accept the loss of flexibility, because there is no way to be safe in both directions at once.

What each wildcard actually forbids, and why

This is the half that gets skipped, and it is the half interviewers probe. The restriction is not arbitrary — in each case the compiler cannot name a type that would be safe.

static void read(List<? extends Number> producer) {
    Number n = producer.get(0);      // fine — whatever it holds IS a Number
    producer.add(1);                 // ERROR
}
error: incompatible types: int cannot be converted to CAP#1
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number

CAP#1 is the thing to understand here, because it is the most confusing error message in Java generics and it is telling you something precise. The compiler has captured the wildcard into a fresh, unnameable type variable. It knows the list has one specific element type and that the type extends Number. It does not know which one — it could be List<Double> — so there is no value on earth it can prove is safe to add. Not even an Integer. The only legal argument is null.

The mirror image:

static void write(List<? super Integer> consumer) {
    consumer.add(42);                // fine — Integer fits anything above Integer
    Integer i = consumer.get(0);     // ERROR
}
error: incompatible types: CAP#1 cannot be converted to Integer
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object super: Integer from capture of ? super Integer

Adding is safe because every legal element type is Integer or wider. Reading is not, because the list might be a List<Object> full of strings. So the only type you can read out is Object.

Compiled and run on this buildEdit and run
List<Object> anything = new ArrayList<>(List.of("not a number"));
List<? super Integer> consumer = anything;
consumer.add(42);

Object onlyObject = consumer.get(0);
System.out.println("list now            : " + anything);
System.out.println("read back as Object : " + onlyObject.getClass().getSimpleName());
Output
list now            : [not a number, 42]
read back as Object : String

That output is the proof: the list really can contain a String, so Integer i = consumer.get(0) would have been a lie.

The unbounded wildcard is not "a list of anything"

List<?> reads like "a list of any type", and people use it as a loose parameter type. What it actually means is a list of one specific unknown type, which makes it read-only for everything except null:

static void inspect(List<?> any) {
    int size = any.size();           // fine — size() does not mention the type
    Object first = any.get(0);       // fine — everything is an Object
    any.add("x");                    // ERROR: String cannot be converted to CAP#1
    any.add(null);                   // legal — null is assignable to every type
    any.clear();                     // fine — removing does not need the type
}

Use List<?> when you genuinely only need size(), iteration, or clear(). If you need to put anything in, you wanted ? super, and if you reach for a raw List to make the error go away you have turned off type checking for every operation rather than just this one.

Why Collections.copy has the signature it has

The JDK is the best PECS reference you have, because its signatures were argued over. The one worth memorising:

public static <T> void copy(List<? super T> dest, List<? extends T> src)

Destination is written to, so ? super. Source is read from, so ? extends. One method, and it accepts combinations that invariance alone would reject:

Compiled and run on this buildEdit and run
List<Object> destination = new ArrayList<>(Arrays.asList(null, null, null));
Collections.copy(destination, List.of(1, 2, 3));
System.out.println("copied Integers into a List<Object> : " + destination);

List<Number> numbers = new ArrayList<>(Arrays.asList(0, 0));
Collections.copy(numbers, List.of(7L, 8L));
System.out.println("copied Longs into a List<Number>    : " + numbers);
Output
copied Integers into a List<Object> : [1, 2, 3]
copied Longs into a List<Number>    : [7, 8]

Without the wildcards, both of those calls would be compile errors and copy would only work between two lists of exactly the same type — which is the one case where you would not need it.

Reference

The decision table

Your methodParameter typeYou canYou cannot
reads T outList<? extends T>read as T, iterate, size()add anything but null
writes T inList<? super T>add a T or any subtyperead as anything but Object
reads and writesList<T>everythingpass a List of a subtype
neither — structure onlyList<?>size(), clear(), iterate as Objectadd anything but null
you gave upList (raw)everythingrely on the compiler, anywhere in the file

Signatures worth copying

// Producer — the commonest case by far. Note it is `? extends`, not `?`,
// because you want the element type to be usable as a Number.
double sum(Collection<? extends Number> source)

// Consumer.
void drainInto(Collection<? super String> sink)

// Both ends, from the JDK itself.
static <T> void copy(List<? super T> dest, List<? extends T> src)
static <T> boolean addAll(Collection<? super T> c, T... elements)

// A bound on the type parameter is NOT the same as a wildcard. This one
// preserves the relationship between the argument and the return type.
static <T extends Comparable<? super T>> T max(Collection<? extends T> coll)
//         ^ `? super T` here so a Fruit-comparing Apple still works.

// Functional interfaces follow the same rule, which is why Stream.map reads
// the way it does.
<R> Stream<R> map(Function<? super T, ? extends R> mapper)
//                         ^ consumes T           ^ produces R

That last one is the payoff for learning this: Function<? super T, ? extends R> looks like noise until you read it as "takes anything that can accept a T, and gives back anything that is an R". Every JDK functional signature is built this way.

When NOT to use a wildcard

// 1. Return types. A wildcard in a return type pushes the capture onto every
//    caller, and they cannot name it either.
List<? extends Number> bad();          // callers get a read-only list
List<Number> good();                   // say what you return

// 2. When a type parameter expresses a relationship a wildcard cannot.
void swap(List<?> list, int i, int j);        // cannot be implemented directly
<T> void swap(List<T> list, int i, int j);    // trivial

// 3. When the collection is genuinely used both ways. Reach for List<T>
//    and accept that callers must pass an exact match.

The swap case is the classic one: list.set(i, list.get(j)) does not compile through a wildcard, because the captured type of the read and the captured type of the write are not known to be the same. The fix is a private generic helper — which is exactly what the JDK does internally, and it is called wildcard capture.

Scenarios

Your utility method rejects the list the caller has. You wrote void report(List<Order> orders) and a caller holds a List<PriorityOrder>. Invariance says no, and the caller's workaround is a copy or a cast — both worse than the fix. If report only reads, widen it to List<? extends Order> and the problem disappears with no runtime cost. This is the most common real appearance of variance, and it is a one-word change.

Someone suggests using arrays because "generics are annoying". They are trading a compile error for an ArrayStoreException, and the exception will not land on the line that is wrong. There is also a second reason not to: you cannot make a generic array, so the moment the element type has a type parameter you are back to (T[]) new Object[n] and an unchecked warning. The array's covariance is a compatibility artefact, not a feature to design toward.

A wildcard is spreading through your codebase. One method took List<? extends Event>, and now the type has propagated into a field, a return type and three other signatures, and none of them can add anything. This is the smell that a wildcard is in a return type somewhere. Wildcards belong on parameters, where they widen what a caller may pass. In a return type they narrow what a caller may do, and the narrowing spreads. Find the returning method and give it a concrete type.

You need both read and write, and the wildcards are fighting you. This is the case with no clean answer. List<T> forces exact matches on callers; ? extends and ? super each forbid half of what you need. The honest options are: split the method into a producer half and a consumer half, make the method generic in T and let inference do the work at each call site, or use a private capture helper. Pick the first if the two halves are meaningful operations, and the second if they are not.

Interviewer's Next Move

1. "Arrays are covariant and generics are not. Is that inconsistent?" It is inconsistent, and it is deliberate. Array covariance predates generics and cannot be removed without breaking every program; the cost is a runtime store check on every array write and ArrayStoreException when it fails. Generics were designed later and chose compile-time prevention instead. If you were designing arrays today you would make them invariant too.

2. "What is PECS, and how do you decide which to use?" Producer extends, Consumer super. The usable version is a question about your own method: does it read from the collection or write to it? Reading makes it a producer, so ? extends T; writing makes it a consumer, so ? super T. If it does both, no wildcard works and you use List<T>.

3. "Why can't you add to a List<? extends Number>?" Because the compiler captures the wildcard as a fresh type variable — the CAP#1 in the error — and knows only that it extends Number. The list might be a List<Double>, so adding an Integer would corrupt it. There is no value that is provably safe for every possible element type, except null.

4. "What can you read out of a List<? super Integer>?" Object, and nothing more specific. The element type is Integer or something wider, and the widest possibility is Object, so that is the only type the compiler can guarantee. Adding is the safe direction there, which is exactly the mirror of ? extends.

5. "Why is Stream.map declared Function<? super T, ? extends R>?" Because the mapper consumes a T and produces an R, so each half follows PECS independently. It means a Function<Object, String> can be passed where a Function<String, CharSequence> is expected, which is the flexibility callers actually want. Every functional signature in the JDK is built from the same rule.

Code traps

List<Integer> ints = List.of(1, 2, 3);
List<? extends Number> nums = ints;
Number n = nums.get(0);
nums.add(null);
nums.add(4);
Answer

The first three lines are fine and nums.add(null) is legal. nums.add(4) does not compile: int cannot be converted to CAP#1. null is the exception because it is assignable to every reference type, so it is safe whatever the captured type turns out to be — though adding null to a list you cannot otherwise write to is rarely something you want.

static void swap(List<?> list, int i, int j) {
    list.set(i, list.get(j));
}
Answer

Does not compile. list.get(j) produces the captured type CAP#1, and list.set(i, …) wants CAP#2 — the compiler does not know the two captures are the same list's type, even though they obviously are. The fix is a private generic helper, <T> void swapHelper(List<T> list, int i, int j), called from the wildcard method. That pattern has a name: wildcard capture.

Common wrong answers

Said in interviewsReality
"List<String> is a List<Object> because String extends Object."Inheritance of the element type says nothing about the generic type. They are unrelated.
"Generics are invariant because of erasure."Unrelated. Invariance is a typing rule; erasure is an implementation detail. C# reifies generics and they are still invariant by default.
"List<?> means a list of anything, so you can add anything."It means a list of one unknown type. You can add only null.
"? extends makes the list read-only."It makes it unwritable, not immutable. clear() and remove(int) still work — they do not mention the element type.
"Use ? extends on the return type to be flexible."That pushes the capture onto every caller. Wildcards belong on parameters.
"Arrays being covariant is a feature."It is a 1.0 compatibility artefact that costs a runtime check on every array store.

Check Yourself

Q1. You have void process(List<Order> orders) and a caller holding a List<RushOrder>. What is the one-word change, and what does it cost you?

AnswerWiden the parameter to List<? extends Order>. It costs nothing at runtime and one thing at compile time: process can no longer add to the list. If it needs to add, the wildcard is wrong and you need List<Order> with the caller converting, or a type parameter.

Q2. What does CAP#1 mean in a generics error message?

AnswerIt is a fresh type variable the compiler invented to stand for the wildcard's unknown-but-specific type — wildcard capture. It appears whenever a wildcard reaches a position that needs an actual type. Reading it as "the one real element type, which I cannot name" makes most of these errors obvious.

Q3. Why does Collections.copy take List<? super T> and List<? extends T> rather than two List<T>s?

AnswerBecause with two List<T> it would only copy between lists of identical type, which is the case where you least need it. The wildcards let it copy a List<Integer> into a List<Object> — destination is written to, so ? super; source is read from, so ? extends. It is PECS in one signature.


Practice

What changed, and when

Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.

  1. Java 5 (1.5)

    Generics arrive invariant, with wildcards as the opt-in for controlled variance — and arrays keep their covariance for compatibility, so the language now has both rules at once.

    Before Java 5 (1.5): Everything was Object-typed, so the question could not be asked: a List held Objects and every read was a cast you wrote yourself.

  2. Java 8LTS

    Target typing makes generic method inference far better, so explicit type witnesses like Collections.<String>emptyList() largely stop being necessary.

    Before Java 8: Inference did not flow through method arguments, and a nested generic call often needed the type spelled out by hand.

  3. Java 10

    var infers the wildcard capture, so `var first = producer.get(0)` gives you the captured type where writing the declaration out was impossible.

    Before Java 10: You had to name the type, and for a captured wildcard there is no name to write — the only option was the erased bound.

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

Where this question goes next

Questions that lead here

  • What is type erasure, and what does it prevent?

    Generics are checked by the compiler and then mostly discarded, so at runtime a List<String> and a List<Integer> are the same class. That is why you cannot write new T[] or instanceof List<String>. But erasure is not total — type arguments survive in the class file for fields, method signatures and supertypes, which is the entire reason Jackson can deserialise a List<Order>.

    Asked constantlyintermediate2–10 yrs11 min readGenerics

Every runnable example above was compiled and executed against openjdk 21.0.12 on this build, and its output diffed against what this page claims. Last updated 2026-09-13.