Advanced Generics: Wildcards & Type Erasure

Move past basics. By the end you'll design bounded generic methods, read wildcard signatures fluently with PECS, and know exactly what type erasure takes away — and how to work around it.

Learn Advanced Generics: Wildcards & Type Erasure in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise…

Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

This lesson builds on a few things you've already met. You should be comfortable with:

Picture three kinds of boxes arriving at a warehouse, and you'll never confuse the wildcards again.

A read-only fruit box . You can take items out (each one is at least a Fruit), but you can't put anything in — nobody told you if it's an apple box or an orange box.

A write-only apple-friendly box . You can drop Apples in, but when you read items back all you know is they're Object .

A plain apple box . You can both add Apples and read Apples. Full access — but it only accepts that one exact type.

The whole lesson is just learning which box to ask for. The rule that decides is PECS , which you'll meet in Section 2.

A plain means "any type at all" — which is safe, but you can only call Object methods on it. A bound restricts what T can be so you can call more useful methods. You write it with extends :

Read as "T can be Number or any subtype of it" (so Integer , Double , …). Note Java always uses extends here — even for interfaces. is fine even though Comparable is an interface you'd normally implement .

To compare two values you need them to be Comparable . But Comparable of what ? You want "comparable to its own type". That's a recursive bound — the type parameter appears inside its own bound:

A wildcard is the ? you sometimes see inside angle brackets. It means "some specific type, but I'm not naming it". The two bounded forms are the ones that matter:

🧠 PECS — the golden rule: Producer Extends, Consumer Super. If a parameter produces values you read out, use ? extends . If it consumes values you write in, use ? super . If it does both, use a plain named type.

The JDK's own Collections.copy(List<? super T> dest, List<? extends T> src) is the canonical example: src produces ( extends ), dest consumes ( super ).

You rarely have to spell out type arguments. The compiler infers them from the values you pass and from what you assign the result to.

You can name the type explicitly with a "witness" — Main.<Integer>maxOf(3, 7) — but you almost never need to. Inference keeps generic code readable.

Generics are a compile-time feature. After the compiler has finished checking your types, it erases them — and both become plain List at runtime, sharing one class object.

That erasure buys backward compatibility with old Java, but it takes a few things away. None of these compile:

Since the type is gone at runtime, you hand it back in as data: pass String.class (a ) and use token.cast(...) / token.isInstance(...) . For nested generics like — which .class can't express — the trick is a super type token : an anonymous subclass of a generic base class whose type argument you read back via reflection (the pattern Jackson's TypeReference and Guice's TypeLiteral use).

Heap pollution is when a variable of a generic type secretly points at the wrong type. Generic varargs ( T... args ) create a hidden array whose element type is erased, so the compiler warns. If your method only reads that array and never stores a bad value into it, the warning is a false alarm — annotate the method with @SafeVarargs to promise that and silence the warning at the declaration.

Excellent work. You can now read and write the generic signatures that scared you off before: bounded parameters, recursive bounds, and the ? extends / ? super wildcards that PECS tells you to pick. You also know what type erasure removes at runtime and how Class<T> tokens, super type tokens, and @SafeVarargs get you around it.

Next up: Streams API — combine filter, map, collect, and flatMap into clean data pipelines, leaning on the generics fluency you just built.

Practice quiz

In <T extends Number>, what does the bound let you do inside the method?

  • Call only Object methods on T
  • Create new T() instances
  • Call Number methods like doubleValue() on T
  • Add any type to a List<T>

Answer: Call Number methods like doubleValue() on T. Bounding T to Number means every T is at least a Number, so you can call Number methods such as doubleValue().

What does PECS stand for?

  • Producer Extends, Consumer Super
  • Parameter Erasure, Class Substitution
  • Producer Super, Consumer Extends
  • Polymorphic Extends, Concrete Super

Answer: Producer Extends, Consumer Super. PECS = Producer Extends, Consumer Super: read from a producer with ? extends T, write to a consumer with ? super T.

Given List<? extends Number> producer, which operation is allowed?

  • producer.add(1)
  • producer.add(new Object())
  • Both adding and reading freely
  • Reading elements as Number

Answer: Reading elements as Number. A ? extends wildcard is a producer: you can read elements as Number but cannot add (the exact subtype is unknown).

Why does <T extends Comparable<T>> include T inside Comparable?

  • It is a typo with no effect
  • It is a recursive bound ensuring T is comparable to its own type
  • It makes T a wildcard
  • It allows comparing T to any unrelated type

Answer: It is a recursive bound ensuring T is comparable to its own type. The recursive bound guarantees a.compareTo(b) is type-safe between two T values, exactly what Collections.max/sort require.

Because of type erasure, what do List<String> and List<Integer> share at runtime?

  • One runtime class, List
  • Nothing — they are different classes
  • A common String element type
  • A shared static field

Answer: One runtime class, List. Erasure removes type arguments at runtime, so both become plain List and share one runtime class object. Verified true on Java 21.

Which of these is ILLEGAL because of type erasure?

  • List<String> list = new ArrayList<>()
  • token.cast(x) with a Class<T>

You cannot write new T(), new T[n], or instanceof List<String> after erasure; workarounds include a Class<T> token.

What is a Class<T> type token used for?

  • To make a class final
  • To recover type information at runtime via cast/isInstance, beating erasure
  • To bound a wildcard
  • To create generic arrays

Answer: To recover type information at runtime via cast/isInstance, beating erasure. Passing String.class (a Class<String>) lets you use token.cast(...) and token.isInstance(...) since the type argument is gone at runtime.

Why does @SafeVarargs exist?

  • To make varargs faster
  • To allow more than one vararg parameter
  • To convert checked to unchecked exceptions
  • To document that a generic-varargs method only reads its array, silencing the heap-pollution warning

Answer: To document that a generic-varargs method only reads its array, silencing the heap-pollution warning. Generic varargs create a hidden array of an erased type; @SafeVarargs promises the method only reads it, suppressing the false heap-pollution warning.

Why can't you assign a List<Dog> to a List<Animal> even though Dog extends Animal?

  • Generics are covariant
  • Generics are invariant; allowing it would let you add a Cat to a List<Dog>
  • Dog does not really extend Animal
  • Lists are immutable

Answer: Generics are invariant; allowing it would let you add a Cat to a List<Dog>. Generics are invariant; if the assignment were allowed you could add a Cat through the List<Animal> view, breaking type safety. Use wildcards for variance.

What is the difference between <T extends Number> and <? extends Number>?

  • They are identical
  • The wildcard can be returned but the named one cannot
  • The named T can be returned and related across arguments; the wildcard is unnamed and read-only
  • Only the wildcard allows adding elements

Answer: The named T can be returned and related across arguments; the wildcard is unnamed and read-only. A named type parameter ties the type across arguments and the return value; the wildcard means 'some unknown subtype' and only supports reading.