This content originally appeared on Level Up Coding - Medium and was authored by Vinod Madubashana
Things you need to know to master Java Generics
In Java, generics can be used to implement containers or methods that execute the same logic for different types. For example, List<String> will hold String objects, and List<Integer> will hold Integer objects. This List class is a generic class that has a generic type.
This is not an article introducing Java generics, so I assume you have some basic understanding of Java generics.
Table of Contents
· Types behavior
∘ The Unbounded Wildcard: ?
∘ The ? extends T: Covariance
∘ The ? super T: Contravariance
∘ Quick Summary Table
∘ Real-life Analogy
∘ Wildcards in Real APIs: Function Explained
· Type Erasure: The Hidden Mechanism Behind Generics
∘ Why Type Erasure?
∘ A Simple Example
∘ What You Can and Can’t Do Because of Erasure
∘ Type Erasure and Method Overloading
∘ How Bounded Types Work with Erasure
∘ Using & in Generic Bounds: The Case of Collections.max() and Backward Compatibility
· Complications with Arrays and Generics: Reification and Covariance
∘ Arrays Are Reified and Covariant
∘ Generics Are Erased and Invariant
∘ Why You Can’t Create Generic Arrays
∘ Workarounds for Creating Generic Arrays
∘ Summary: Arrays vs. Generics
Types behavior
In Java Generics, we use a named type parameter. For example, see the following example of the ArrayList class definition
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable
Here, E is the type parameter. In this case, ArrayList is a container class that keeps items of type E. When considering types in any object-oriented programming language, one of the most important features is the ability to have an inheritance relationship, which means the ability to have subtypes where the functionality can be overridden or improved over its supertype. These subtypes can be referenced through their super or parent type, and that's where it comes the power of changing the object's runtime behavior dynamically, which is also explained as polymorphism (The ability to reference any subtype using its parent type).
The Unbounded Wildcard: ?
When working with generics in Java, sometimes you want to make your code more flexible to accept a range of types rather than a specific one. This is where wildcards come into play.
The ? (unbounded wildcard) represents an unknown type. You can think of it as "some type, I don't care which." It's useful when you're reading data but not writing to it.
For example:
List<?> unknownList = new ArrayList<String>();
Here, we are saying: “unknownList can be a list of any type”. But since we don’t know the specific type, we cannot safely add elements to this list (except null), because the compiler cannot guarantee type safety.
The ? extends T: Covariance
The ? extends T wildcard is used when you want to accept a type or any of its subtypes. This is commonly referred to as covariance, meaning it preserves the inheritance hierarchy in a read-only way.
Example:
class Animal { void feed() { System.out.println("Feeding animal"); } }
class Dog extends Animal { void bark() { System.out.println("Barking"); } }
public void processAnimals(List<? extends Animal> animals) {
for (Animal a : animals) {
a.feed(); // allowed: feed() is in Animal
// a.bark(); // not allowed: Animal does not have bark()
}
}
You can pass a List<Dog> or List<Cat> (if Cat also extends Animal) to processAnimals, because they are subtypes of Animal.
However, you cannot add new elements to animals (except null). Why? Because the compiler only knows that it's a list of some subtype of Animal. You might be passing a List<Dog>, and if the method tries to add a Cat, it breaks type safety. Hence, it is read-only.
The ? super T: Contravariance
The ? super T wildcard is used when you want to accept a type or any of its supertypes. This is called contravariance. It is used when you want to write to a generic structure, but you don’t need to read specific subtypes out of it.
Example:
class Animal { void feed() { System.out.println("Feeding animal"); } }
class Dog extends Animal { void bark() { System.out.println("Barking"); } }
public void addDogs(List<? super Dog> dogs) {
dogs.add(new Dog()); // allowed
// dogs.add(new Animal()); // not allowed, might not be valid
}
You can pass a List<Dog>, List<Animal>, or even List<Object> to this method. Since you’re only writing Dog objects, it’s safe to treat the list as a collection of some supertype of Dog.
However, when reading from the list, the type you get is just Object, because that’s the only type the compiler can safely guarantee.
Quick Summary Table

Real-life Analogy
Let’s say Animal is a general category, and Dog is a specific breed.
- Using ? extends Animal is like saying: "I will bring some animal, and you can feed it, but you can't train it unless you know exactly what it is."
- Using ? super Dog is like saying: "Here’s a place where I can put any dog, and the person accepting it just knows how to handle animals, not necessarily dogs."
Understanding how extends and super work helps you design flexible APIs. Always remember:
Use extends when you only need to read data from a structure. Use super when you only need to write data. If you need both, avoid wildcards or reconsider the design.
Wildcards in Real APIs: Function<T, R> Explained
Java’s Function<T, R> interface from the java.util.function package is a perfect real-world example of how ? extends and ? super are used thoughtfully in API design to support flexibility with type safety.
Let’s take a closer look at its definition:
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}
static <T> Function<T, T> identity() {
return t -> t;
}
}
Let’s break down compose() and andThen() to see why they use ? super and ? extends.
compose(Function<? super V, ? extends T> before)
This method creates a new function that first applies the before function, and then applies the current function. It allows you to compose functions like: f(g(x)).
Why <? super V, ? extends T>?
- ? super V: The input of the before function can be V or any of its supertypes. This is contravariant, allowing flexibility in accepting broader inputs.
- ? extends T: The output of the before function must be a subtype of T, so that it can be safely passed to the current function's apply(T t) method. This is covariant.
Example:
Function<Number, String> numberToString = n -> "Number: " + n;
Function<Integer, Number> intToNumber = i -> i; // Integer -> Number
Function<Integer, String> composed = numberToString.compose(intToNumber);
System.out.println(composed.apply(10)); // Output: Number: 10
Here:
- before: Function<Integer, Number> → fits Function<? super V, ? extends T> where V = Integer, T = Number
- this: Function<Number, String>
- Result: Function<Integer, String>
So, compose() allows us to "pre-process" the input before applying the main function.
andThen(Function<? super R, ? extends V> after)
This method chains the current function with another function that will process the result. It allows you to express: after(f(x)).
Why <? super R, ? extends V>?
- ? super R: The after function must accept R or its supertype — this is contravariant with respect to the result of the current function.
- ? extends V: The output of after can be any subtype of V, giving covariant flexibility for return types.
Example:
Function<Integer, Double> intToDouble = i -> i * 2.5;
Function<Number, String> numberToString = n -> "Value: " + n;
Function<Integer, String> chained = intToDouble.andThen(numberToString);
System.out.println(chained.apply(4)); // Output: Value: 10.0
Here:
- this: Function<Integer, Double>
- after: Function<Number, String> → fits Function<? super R, ? extends V> where R = Double, V = String
- Result: Function<Integer, String>
So, andThen() lets us post-process the output of the current function.
Key Takeaways
- ? super T is used where the value will be passed in to a function (write).
- ? extends T is used where the value will be read out of a function (read).
- These wildcards let Java’s standard functional interfaces like Function<T, R> support flexible function composition while preserving type safety.
This is a textbook example of how extends and super work together in a real-world API. These keywords allow the library designers to open up the flexibility of type bounds without compromising the integrity of the type system.
Type Erasure: The Hidden Mechanism Behind Generics
Java generics are a compile-time feature. They give us the ability to write type-safe code that works with many types, but this type information doesn’t exist at runtime. This is due to a concept called type erasure.
Type erasure means that all generic type information is removed by the compiler after ensuring type correctness. What’s left at runtime is the raw type.
Why Type Erasure?
Java introduced generics in Java 5, but it had to stay backward-compatible with older versions of the Java Virtual Machine (JVM) that didn’t understand generics. So, instead of creating a new kind of object for List<String>, the compiler just turns it into List and inserts type casts where needed.
A Simple Example
Let’s look at a generic class:
class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
When you use this class:
Box<String> stringBox = new Box<>();
stringBox.set("Hello");
String value = stringBox.get();
Under the hood, after type erasure, the compiler rewrites this to something like:
class Box {
private Object value;
public void set(Object value) { this.value = value; }
public Object get() { return value; }
}
And your code becomes:
Box stringBox = new Box();
stringBox.set("Hello");
String value = (String) stringBox.get(); // cast inserted by compiler
So, the type safety is enforced at compile-time, but lost at runtime.
What You Can and Can’t Do Because of Erasure
Because type information is erased, you cannot do certain things with generics:
❌ You can’t use instanceof with generic types:
if (box instanceof Box<String>) // Compilation error
Only this is allowed:
if (box instanceof Box) // OK
❌ You can’t create generic arrays:
List<String>[] list = new ArrayList<String>[10]; // Compilation error
❌ You can’t get the class of a generic type parameter:
class MyClass<T> {
void printType() {
System.out.println(T.class); // Compilation error
}
}
Type Erasure and Method Overloading
Type erasure also affects method signatures. For example:
public class Test {
public void print(List<String> list) {}
public void print(List<Integer> list) {} // Compilation error
}
Both methods erase to the same signature:
void print(List list)
So the compiler can’t distinguish between them after erasure.
How Bounded Types Work with Erasure
When you use bounded types like <T extends Number>, the compiler erases the type to the bound.
class MyClass<T extends Number> {
T value;
}
After erasure, it becomes:
class MyClass {
Number value;
}
If no bound is specified, the type is erased to Object.
Using & in Generic Bounds: The Case of Collections.max() and Backward Compatibility
In Java, when defining a generic type parameter that must conform to multiple types, we can use intersection types with the & symbol. This allows us to say, “a type must extend or implement all of the following types.”
A classic and elegant example of this appears in the Java Collections Framework. Following is a method available in Java Collections utility class.
public static <T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll)
Let’s break this down and explain why it was designed this way — and how it’s related to type erasure and backward compatibility with pre-generics Java.
What Does the Declaration Mean?
<T extends Object & Comparable<? super T>>
This declares a type parameter T that must:
- Be a subtype of Object (which every class in Java already is), and
- Implement Comparable for some supertype of T.
This allows the method to compare two elements of type T using compareTo, while still supporting types that compare themselves to supertypes (e.g., enums, which implement Comparable<Enum>).
Why Use Object & Comparable<? super T> Instead of Just Comparable<? super T>?
The key lies in type erasure and Java’s commitment to binary compatibility.
When generics were added in Java 5, existing class files compiled with Java 1.4 or earlier still needed to work. The original (pre-generics) version of max() looked like this:
public static Object max(Collection coll) // Java 1.4
After generics, the erased version of the generic max() method must still match this signature for compatibility.
Now, here’s the trick:
When you use multiple bounds with &, Java picks the leftmost bound as the erasure type.
So:
<T extends Object & Comparable<? super T>>
gets erased to:
public static Object max(Collection coll)
✅ Perfect match with the pre-generic version!
Had the declaration been:
<T extends Comparable<? super T>>
Then the erased return type would have been Comparable, which would break compatibility with older binaries expecting Object.
Thus, the explicit inclusion of Object as the leftmost bound is intentional and serves one specific purpose: preserve the erased return type as Object, just like before generics.
Complications with Arrays and Generics: Reification and Covariance
One of the most subtle and error-prone areas in Java generics arises when they interact with arrays. At first glance, arrays and generic containers like List<T> may seem interchangeable — they both hold elements — but they differ drastically in two key ways:
- Reification: Arrays carry their component type at runtime. Generics do not.
- Covariance: Arrays are covariant. Generic containers are not.
These differences make arrays incompatible with generic types in important ways, and often lead to compile-time restrictions or runtime errors.
Arrays Are Reified and Covariant
Reification means that the type information is preserved at runtime. Arrays in Java are reified:
String[] strings = new String[10];
Object[] objects = strings; // ✅ Allowed: arrays are covariant
objects[0] = 42; // ❌ Runtime error: ArrayStoreException
Even though Object[] can reference a String[], the runtime will still enforce type safety and throw an exception when the wrong type is assigned.
Arrays are also covariant, meaning that T[] is considered a subtype of Object[] if T is a subtype of Object.
Generics Are Erased and Invariant
Generic types, however, are invariant by default — meaning:
List<String> stringList = new ArrayList<>();
List<Object> objectList = stringList; // ❌ Compile-time error
Even though String is a subtype of Object, List<String> is not a subtype of List<Object>. This decision was made to prevent unsafe operations — like putting an Integer into a List<String> — which would not be caught until runtime due to erasure.
This is where type safety wins over convenience.
Why You Can’t Create Generic Arrays
Because generics use type erasure, the type parameter T is not available at runtime. Java can't enforce the array's component type after compilation. Consider:
List<String>[] array = new List[10]; // Warning: unchecked assignment
Object[] objArray = array;
objArray[0] = List.of(1, 2, 3); // Unsafe! No ArrayStoreException
String s = array[0].get(0); // ❌ ClassCastException at runtime
This demonstrates heap pollution — the array holds a List<Integer> where a List<String> is expected, but there's no runtime protection against it.
That’s why generic array creation is disallowed in Java:
List<String>[] array = new List<String>[10]; // ❌ Compile-time error
Workarounds for Creating Generic Arrays
While generic arrays aren’t allowed, there are safe patterns using:
1. Wildcard Arrays with Casting (with caution):
@SuppressWarnings("unchecked")
List<String>[] array = (List<String>[]) new List<?>[10];
This compiles but is unchecked and potentially unsafe.
2. Type Tokens with Reflection:
public class ArrayFactory<T> {
private final Class<T> type;
public ArrayFactory(Class<T> type) {
this.type = type;
}
@SuppressWarnings("unchecked")
public T[] createArray(int size) {
return (T[]) Array.newInstance(type, size);
}
}
ArrayFactory<String> factory = new ArrayFactory<>(String.class);
String[] strings = factory.createArray(5);
This technique preserves reified type information via a Class<T> token and avoids unchecked warnings.
Summary: Arrays vs. Generics

Java generics offer powerful tools for building type-safe, reusable components — but with that power comes nuance. From wildcards to erasure, from covariance to type safety, understanding the tricky edges of generics is essential for writing robust and maintainable code. These subtleties may seem daunting at first, but with thoughtful use, they can help you design APIs that are both flexible and safe.
Tricky Parts in Java Generics was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.
This content originally appeared on Level Up Coding - Medium and was authored by Vinod Madubashana

Vinod Madubashana | Sciencx (2025-05-09T16:18:08+00:00) Tricky Parts in Java Generics. Retrieved from https://www.scien.cx/2025/05/09/tricky-parts-in-java-generics/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.