Skip to content
QSWEQB
mediumConceptCodingMidSeniorStaff

What does the compiler erase from a generic type, and how do you decide between ? extends and ? super?

Generic type arguments exist only for the compiler; the bytecode keeps a raw type plus a signature string, which is why there is no T.class and no new T[]. Wildcards then follow producer-extends, consumer-super: read through ? extends, write through ? super.

4 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate explain erasure as migration compatibility with pre-generics code rather than as an arbitrary limitation
  • Whether they know some generic information survives in the class file and can name what reads it back
  • That arrays being covariant and reified while generics are invariant and erased is presented as the root of the mismatch
  • Can they derive the extends and super rule from what the compiler can prove, instead of reciting the mnemonic
  • Whether an unchecked cast is described as deferring the failure rather than removing it

Answer

Erasure buys backwards compatibility

Generics were added in Java 5 to a language that already had a decade of code passing raw List around, and the design constraint was that the old code had to keep working against the new collections. So the type arguments were made a compile-time device. The compiler checks them, then throws most of them away: every type variable is replaced by its leftmost bound, Object if the variable is unbounded, and casts are inserted at the points where you read a value out. ArrayList<String> and ArrayList<Integer> are the same class at runtime, and a raw ArrayList is assignment-compatible with both.

That single decision explains the whole list of things you cannot do. There is no T.class, because at runtime there is no T. You cannot write new T[n], because the JVM would need the component type to build the array. instanceof List<String> will not compile, because the check is unanswerable. You cannot overload two methods on List<String> and List<Integer>, because after erasure both have the signature f(List). A type variable cannot appear in a catch clause, and a static method cannot reference the class's own type parameter, since there is no instance to have supplied it.

The half of this that candidates usually get wrong is claiming that generic information is gone entirely. It is not. The class file keeps a Signature attribute recording the declared generic types of fields, methods, superclasses and interfaces, which is what the compiler reads when it compiles against your jar and what reflection exposes through getGenericSuperclass and getGenericParameterType. What is missing is the type argument of a particular instance. That distinction is why Jackson's TypeReference and Spring's ParameterizedTypeReference work at all: you subclass an abstract generic type, the type argument is baked into the anonymous subclass's declaration, and the library reads it back off the superclass.

Arrays and generics disagree, on purpose

An array is reified and covariant: String[] is a subtype of Object[], and the runtime carries the component type so it can police stores. A generic type is erased and invariant: List<String> is not a subtype of List<Object>, and the runtime carries nothing.

final class Variance {
    public static void main(String[] args) {
        Object[] array = new String[1];
        array[0] = 42;    // compiles; throws ArrayStoreException when it runs

        // List<Object> list = new ArrayList<String>();
        // ^ rejected at compile time instead, because there is no later check to make
    }
}

Both rules exist to stop you putting an Integer where a String belongs. Arrays catch it late, at the store, using type information they kept. Generics catch it early, at the assignment, because they will have no type information left to catch it later. Neither is a bug; they are two answers to the same problem, and mixing them is where @SafeVarargs comes from, since a T... parameter has to materialise a T[] that the runtime cannot check.

Deriving PECS rather than memorising it

Invariance is safe but useless on its own, because a method that takes List<Number> cannot be handed a List<Integer>. Wildcards restore the flexibility by admitting exactly what the compiler can still prove.

With List<? extends Number> the element type is some unknown subtype of Number. Reading is fine, because whatever it is, it is a Number. Writing is not, because the compiler cannot prove your Integer belongs in what might be a List<Double>. So ? extends gives you a producer, and the only value you may pass to add is null.

With List<? super Number> the element type is some unknown supertype of Number. Now writing is fine, because a Number fits in any container that holds Number or wider. Reading gives you Object, because that is the only thing every supertype has in common. So ? super gives you a consumer.

import java.util.ArrayList;
import java.util.List;

final class Transfer {

    // src produces T, so ? extends. dest consumes T, so ? super.
    static <T> void transferAll(List<? extends T> src, List<? super T> dest) {
        for (T item : src) {
            dest.add(item);
        }
        // src.add(item); // rejected: the element type is an unknown subtype of T
    }

    public static void main(String[] args) {
        List<Integer> ints = List.of(1, 2, 3);
        List<Number> numbers = new ArrayList<>();
        transferAll(ints, numbers);

        // Same class object: the type argument left no trace.
        System.out.println(ints.getClass() == numbers.getClass());   // false, different impls
        System.out.println(new ArrayList<String>().getClass()
                == new ArrayList<Integer>().getClass());             // true
    }
}

The corollary worth volunteering is that wildcards belong on parameters, not on return types. Returning List<? extends Number> pushes the wildcard into every caller's local variable and buys nothing, since the caller already knows what it asked for.

The unchecked cast is a promise, not a check

The habit that separates a careful answer is what you do when erasure blocks you and you reach for a cast. Casting a raw List to List<String> compiles with an unchecked warning and inserts no runtime check at all, because there is nothing to check. If the list actually holds an Integer, nothing happens at the cast. The ClassCastException fires later, in the caller's code, at the implicit cast the compiler inserted where an element was read — a stack trace pointing at a line that did nothing wrong.

So treat an unchecked cast as an assertion you are making on the compiler's behalf, and confine it: put @SuppressWarnings("unchecked") on the narrowest possible declaration, usually a single local variable, and write the comment explaining why the cast holds. Suppressing it on the whole method silences the next three casts somebody adds. Where you can, prefer Class<T> plus cast() as a type token, which pushes the check back to a point where it can actually be performed.

Likely follow-ups

  • Why does the compiler generate a bridge method when you implement Comparable<Foo>?
  • What does @SafeVarargs assert, and when would applying it be a lie?
  • How does Jackson recover the element type of a List<Order> if erasure removed it?
  • Why can you not overload a method on List<String> and List<Integer>?

Related questions

Further reading

genericstype-erasurewildcardsvarianceunchecked-cast