Skip to content

Generics, Type Erasure, and Wildcards

The Problem That Generics Were Born to Solve

Before Java 5 introduced generics, every container class that wanted to be reusable had to store its data as Object. That sounds harmless at first, but it produces two painful problems that you run into the moment you try to do any real work.

Imagine you want a simple wrapper that can hold a value and let you get it back. Without generics, you write it like this:

java
// The pre generics approach
public class Print {
    Object value;          // Object is the parent of every class in Java

    Object getPrintValue() {
        return value;
    }

    void setPrintValue(Object v) {
        this.value = v;
    }
}

This works because Object is the parent of everything. Every class you will ever write in Java is automatically a child of Object, whether you say so or not. So an Object reference can hold an Integer, a String, a custom Bus class, anything at all.

Here is how you use it:

java
Print obj = new Print();

obj.setPrintValue(1);           // passing an Integer — accepted
obj.setPrintValue("hello");     // passing a String — also accepted, no complaint

// Now reading the value back
Object raw = obj.getPrintValue();

// Problem: you must figure out what type it is before you can use it
if (raw instanceof Integer) {
    int num = (int) raw;        // must cast, verbose and fragile
} else if (raw instanceof String) {
    String s = (String) raw;    // must cast again
}

See the two problems? First, nothing stopped you from stuffing both an Integer and a String into the same Print object on consecutive lines. The class has no way to enforce that it stores only one kind of thing. Second, every time you read a value back you must do an instanceof check followed by a cast. If you cast to the wrong type, you get a ClassCastException at runtime, not a friendly compile error. You only find the bug when the code is already running.

Generics solve both problems completely. The idea is that instead of hardcoding Object as the type, you use a placeholder called a type parameter and fill it in when you create the object.


Your First Generic Class

Here is the same Print class rewritten as a generic class:

java
public class Print<T> {
    T value;               // T is the placeholder for the real type

    T getPrintValue() {
        return value;      // returns whatever type T turns out to be
    }

    void setPrintValue(T v) {
        this.value = v;    // accepts only the type T stands for
    }
}

The &lt;T&gt; after the class name is the type parameter declaration. You are saying: this class works with some type, and I will call that type T throughout the class. When someone creates an object, they replace T with a real type.

java
// Creating a Print that holds only Integers
Print<Integer> obj = new Print<>();
obj.setPrintValue(1);        // fine — Integer expected and received
// obj.setPrintValue("hi"); // COMPILE ERROR — String is not Integer

// No cast needed when reading back
int val = obj.getPrintValue();   // already an Integer, assign directly

Now the compiler knows exactly what type is stored. It rejects the wrong type at compile time, and it knows the return type when you call getPrintValue, so no cast is needed. You traded the runtime ClassCastException for a compile error that you see immediately in your IDE.

The angle brackets are often called the diamond operator. On the right side of an assignment you can leave them empty (new Print&lt;&gt;()) and the compiler infers the type from the left side. Both forms are correct:

java
Print<String> p1 = new Print<String>();   // explicit — fine
Print<String> p2 = new Print<>();          // diamond inference — preferred

One critical rule: you cannot use primitive types as type parameters. Print&lt;int&gt; is illegal. Use the wrapper class instead: Print&lt;Integer&gt;, Print&lt;Double&gt;, Print&lt;Boolean&gt;. Java handles the boxing and unboxing automatically.


Type Parameter Naming Conventions

The letter inside the angle brackets is just a name. By strong convention, single uppercase letters are used:

T stands for Type and is the general purpose choice. E stands for Element and is used in collection classes. K and V stand for Key and Value, used in maps. N stands for Number. R stands for Return type, sometimes used in functional interfaces. You can name it anything, but following these conventions makes your code immediately readable to other Java developers.


Inheritance with Generic Classes

Because a generic class is still a class, you can inherit from it. There are two flavors and they behave differently.

Pinning the Type at Inheritance Time

If your subclass is not itself generic, you must fix the type parameter when you write the extends clause:

java
// ColorPrint is NOT generic — it only works with String
class ColorPrint extends Print<String> {
    // T is permanently replaced with String in this class
}

ColorPrint cp = new ColorPrint();
cp.setPrintValue("red");    // fine
// cp.setPrintValue(42);   // compile error — this is a String Print

You pick the concrete type at the inheritance declaration and it is fixed forever for that subclass.

Passing the Type Parameter Through

If your subclass is also generic, you pass T through and the caller decides the type when they create an object:

java
// ColorPrint<T> IS generic — it passes T through to Print<T>
class ColorPrint<T> extends Print<T> {
    // T is still a placeholder, decided at object creation
}

ColorPrint<Integer> cp = new ColorPrint<>();
cp.setPrintValue(42);        // fine — T is Integer here

This is the more flexible form. The type decision travels all the way to the point where the object is created.


Multiple Type Parameters

Nothing limits you to one type parameter. You can declare as many as the class needs. A classic example is a key value pair:

java
class Pair<K, V> {
    K key;
    V value;

    void put(K key, V value) {
        this.key = key;
        this.value = value;
    }
}

When you create a Pair, you supply both types:

java
// K is String, V is Integer
Pair<String, Integer> age = new Pair<>();
age.put("age", 25);         // String key, Integer value — enforced by compiler

The convention K and V is widely recognized. When you see Map&lt;K, V&gt; in the Java standard library, this is exactly the same idea.


Generic Methods

You do not have to make the whole class generic. A single method can declare its own type parameter, completely independent of any class level type parameter. The syntax puts the type parameter declaration before the return type:

java
public class GenericMethodDemo {
    // <T> before the return type makes this a generic method
    public <T> void setValue(T v) {
        System.out.println("Received: " + v);
    }
}

When you call this method, the compiler infers T from the argument you pass:

java
GenericMethodDemo demo = new GenericMethodDemo();
demo.setValue(42);        // T inferred as Integer
demo.setValue("hello");   // T inferred as String
demo.setValue(new Bus()); // T inferred as Bus

Two rules to remember about generic methods. First, the type parameter declaration goes before the return type, not anywhere else. Second, the type parameter T in a generic method is completely local to that method. If the class also has a type parameter called T, they are separate and independent. Renaming one of them to something else makes the code clearer.


Raw Types: The Danger Zone

A raw type is what you get when you use a generic class without providing the type parameter:

java
// Raw type — no type argument given
Print raw = new Print();     // compiler warns: unchecked usage
raw.setPrintValue(42);
raw.setPrintValue("hello");  // accepted — type safety completely gone

Internally the compiler substitutes Object for every type parameter, which is exactly the pre generics world you wanted to escape. Raw types exist only for backward compatibility with old code written before Java 5. Never use them in new code. When you see a raw type warning in your IDE, take it seriously.


Bounded Type Parameters

By default, T can be replaced with absolutely anything. Sometimes you want to restrict that. Bounded type parameters let you say: T can only be this class, or a subclass of it.

Upper Bound with extends

java
class NumericPrinter<T extends Number> {
    T value;

    // Because T must be Number or a subclass, we can call Number's methods
    double doubleValue() {
        return value.doubleValue();   // Number has this method
    }
}

Now the compiler enforces the constraint:

java
NumericPrinter<Integer> ip = new NumericPrinter<>();   // Integer extends Number — OK
NumericPrinter<Double>  dp = new NumericPrinter<>();   // Double extends Number — OK
// NumericPrinter<String> sp = ...;  // COMPILE ERROR: String does not extend Number

The benefit is not just restriction. It is also capability. Without the bound, you cannot call any Number method on T because the compiler has no idea what T is. With T extends Number, the compiler knows T has at least everything Number has, so those calls are valid.

One important thing: the keyword is always extends even when you are bounding by an interface. In regular class declarations you write implements for interfaces, but inside angle brackets you always write extends:

java
// Bounding by an interface — still uses extends, not implements
class Sorter<T extends Comparable<T>> {
    // T can call compareTo() because Comparable guarantees it
}

Multiple Bounds

You can require T to satisfy several constraints at once. The syntax uses & to combine them:

java
// T must extend ParentClass AND implement Interface1 AND implement Interface2
class MultiConstrained<T extends ParentClass & Interface1 & Interface2> {
    // ...
}

The rules here mirror Java's single inheritance: you can have at most one class bound (it must come first), followed by as many interface bounds as you like. This matches the fact that a Java class can extend only one class but implement any number of interfaces.

java
// A class that satisfies this bound
class A extends ParentClass implements Interface1, Interface2 {
    // implements everything required
}

// An object of class A can be used as type T
MultiConstrained<A> mc = new MultiConstrained<>();   // OK

// But a class that only implements Interface1, not Interface2, fails
class B extends ParentClass implements Interface1 { }
// MultiConstrained<B> mc2 = ...;  // COMPILE ERROR — B doesn't implement Interface2

Why Generics Are Invariant: The List Problem

Here is something that surprises almost every developer the first time they see it. Suppose you have this hierarchy:

java
// Vehicle is the parent; Bus and Car are children
class Vehicle { }
class Bus extends Vehicle { }
class Car extends Vehicle { }

With regular object references, covariance works exactly as you expect:

java
Vehicle v = new Bus();   // parent reference holds child object — perfectly valid

But with generic collections, this does NOT work:

java
List<Vehicle> vehicleList = new ArrayList<>();
List<Bus> busList = new ArrayList<>();

// vehicleList = busList;  // INVALID — does not compile
// busList = vehicleList;  // INVALID — also does not compile

Neither direction works. List&lt;Bus&gt; is not a subtype of List&lt;Vehicle&gt;, even though Bus extends Vehicle. This is called invariance.

Why does Java enforce this? The reason is safety. Suppose the assignment vehicleList = busList were allowed. Then you could do this:

java
vehicleList.add(new Car());    // Car is a Vehicle, so this looks legal

But vehicleList is actually pointing at a List&lt;Bus&gt;. You just added a Car to a list that is supposed to contain only buses. When someone later reads from busList and expects a Bus, they get a Car and a ClassCastException. The compiler prevents the assignment entirely to make this impossible.


Wildcards: Getting Flexibility Back

Invariance is safe, but it can be frustrating when you want to write a method that works with a list of vehicles and also with a list of buses. That is what wildcards are for. The wildcard is written as ? and represents an unknown type.

Upper Bounded Wildcard: Reading a Family of Lists

java
// Accepts List<Vehicle>, List<Bus>, List<Car> — anything that is a Vehicle
void processVehicles(List<? extends Vehicle> vehicles) {
    for (Vehicle v : vehicles) {   // reading is safe — every element is at least a Vehicle
        v.drive();
    }

    // vehicles.add(new Bus());    // COMPILE ERROR — cannot add to a wildcard list
}

The ? extends Vehicle means: a list of some unknown type that extends Vehicle. You can read elements because you know they are at least Vehicle. You cannot add elements because you do not know the exact type. The list might be a List&lt;Bus&gt; and adding a Car to it would be wrong. The compiler refuses to let you add anything.

Lower Bounded Wildcard: Writing into a Family of Lists

java
// Accepts List<Vehicle>, List<Object> — anything that is a supertype of Vehicle
void addVehicles(List<? super Vehicle> destination) {
    destination.add(new Bus());    // safe — Bus is a Vehicle, which is a the unknown type
    destination.add(new Car());    // also safe

    // Object obj = destination.get(0);  // reading gives only Object — not very useful
}

The ? super Vehicle means: a list of some unknown type that is Vehicle or above. You can add Vehicle and its subtypes because any list that holds Vehicles or more general things can safely accept a Vehicle. Reading is almost useless though because the best the compiler can promise you is Object.

Unbounded Wildcard: Any List at All

java
// Accepts any list regardless of element type
void printAll(List<?> list) {
    for (Object o : list) {
        System.out.println(o);   // can only call Object methods
    }
}

Use unbounded wildcards when your method only needs Object methods like toString or equals, and you truly do not care what the element type is.


PECS: The Rule That Makes Wildcards Click

There is a memorable rule for choosing between upper and lower bounded wildcards:

Producer Extends, Consumer Super.

If a collection is producing data that your code reads from, use ? extends. If a collection is consuming data that your code writes into, use ? super.

Here is a concrete example. Suppose you want to copy elements from a source list into a destination list:

java
void copy(List<? extends Number> source, List<? super Number> destination) {
    for (Number n : source) {        // source PRODUCES — we extend
        destination.add(n);          // destination CONSUMES — we super
    }
}

source is a producer: you are pulling numbers out of it. Use extends. destination is a consumer: you are pushing numbers into it. Use super. This lets you call the method with a wide variety of list types:

java
List<Integer> ints = List.of(1, 2, 3);    // Integer extends Number
List<Number> numbers = new ArrayList<>();   // Number is a supertype of Number

copy(ints, numbers);    // works perfectly

Wildcards versus Generic Type Parameters

A common source of confusion is when to use a wildcard and when to use a type parameter. Here is a clear comparison:

java
// Wildcard version: source and destination can be DIFFERENT subtypes of Number
void computeWild(List<? extends Number> source, List<? extends Number> destination) {
    // source could be List<Integer>, destination could be List<Float> — both accepted
}

// Generic type version: source and destination must be the SAME type
<T extends Number> void computeTyped(List<T> source, List<T> destination) {
    // if source is List<Integer>, destination must also be List<Integer>
}

In practice:

Use a type parameter when you need to enforce that multiple arguments share the exact same type. Use a type parameter when you need more than one type variable (like K and V). Use a wildcard when you need a lower bound (super), because lower bound type parameters do not exist in Java. Use a wildcard when the method only cares about one position and flexibility across subtypes is more important than sameness.


Type Erasure: What the JVM Actually Sees

Here is the part that surprises most developers: generics exist only at compile time. By the time the compiler produces bytecode, every trace of the type parameters has been removed. The JVM has never heard of generics. This process is called type erasure.

What the compiler actually does during compilation:

For unbounded type parameters like T, the compiler replaces every occurrence with Object. For bounded type parameters like T extends Number, the compiler replaces every occurrence with the bound class, in this case Number. The compiler also inserts casts in the calling code wherever it needs to recover the specific type.

Here is what that looks like in practice:

java
// Source code you write
Print<Integer> p = new Print<>();
p.setPrintValue(42);
int val = p.getPrintValue();

// What the bytecode is equivalent to after erasure
Print p = new Print();             // raw type
p.setPrintValue(42);               // Integer autoboxed to Object inside the method
int val = (Integer) p.getPrintValue();  // compiler inserted this cast for you

The cast that would have been your problem in the pre generics world is still there in the bytecode. The difference is that the compiler generates it for you and, more importantly, guarantees it is correct because it already verified the types at compile time.

For a bounded type parameter, the erasure uses the bound instead of Object:

java
// Source code
class NumericPrinter<T extends Number> {
    T value;
    double doubleValue() { return value.doubleValue(); }
}

// After erasure, equivalent to
class NumericPrinter {
    Number value;              // T replaced with Number, the upper bound
    double doubleValue() { return value.doubleValue(); }
}

Generic methods follow the same pattern:

java
// Source: generic method
public <T> void setValue(T v) {
    System.out.println(v);
}

// After erasure, equivalent to
public void setValue(Object v) {
    System.out.println(v);
}

Consequences of Type Erasure

Type erasure is not just an implementation detail. It has direct consequences for what you can and cannot do with generics.

You Cannot Do instanceof with a Generic Type

java
List<Integer> list = new ArrayList<>();
// if (list instanceof List<Integer>) { }  // COMPILE ERROR
// At runtime, List<Integer> and List<String> are both just List — indistinguishable

The type argument is gone at runtime. You can only check instanceof List&lt;?&gt; or just instanceof List.

You Cannot Create a Generic Array

java
// new T[10]  // COMPILE ERROR
// Arrays carry their element type at runtime for safety checks
// Since T is erased, the runtime cannot enforce the array type

If you need a collection instead of an array, use ArrayList&lt;T&gt;. If you truly need an array and understand the risk, you can create new Object[10] and cast it, but you will get an unchecked warning.

You Cannot Create an Instance of T

java
// T obj = new T();  // COMPILE ERROR
// At runtime T is just Object — the JVM has no idea what constructor to call

If you need to create instances of the type, pass a Class&lt;T&gt; object and use reflection: clazz.getDeclaredConstructor().newInstance().

Two Methods That Differ Only by Generic Type Are Actually the Same

java
// These two methods LOOK different but are identical after erasure
void process(List<Integer> list) { }
void process(List<String> list) { }   // COMPILE ERROR: erasure makes them the same signature

The compiler rejects this because after erasure both become void process(List list).


Why Java Uses Type Erasure

You might wonder why Java erases generic type information instead of keeping it around at runtime. The answer is backward compatibility. When generics were added in Java 5, there was already a massive ecosystem of libraries and frameworks compiled without generics. If the JVM had been changed to require type information at runtime, none of that existing bytecode would have worked.

Erasure allowed the Java team to add generics as a purely compile time feature. Old bytecode kept working. New code could use generics for safety. The JVM never needed to change. The tradeoff is the limitations described above.


Interview Questions on Generics

What is a generic class and why do we use it? A generic class uses type parameters instead of concrete types, allowing you to write code once that works safely with many types. It moves type errors from runtime ClassCastExceptions to compile time errors.

Can you use primitive types as generic type arguments? No. You must use wrapper classes: Integer instead of int, Double instead of double, and so on. The compiler autoboxes and unboxes automatically.

What is the difference between List&lt;?&gt;, List&lt;? extends Number&gt;, and List&lt;? super Number&gt;? List&lt;?&gt; accepts a list of any type but you can only read elements as Object. List&lt;? extends Number&gt; accepts lists of Number and any subclass, lets you read as Number, but you cannot add elements. List&lt;? super Number&gt; accepts lists of Number and any superclass, lets you add Numbers and subtypes, but you can only read elements as Object.

What is type erasure? At compile time, the Java compiler checks all generic types for correctness, then removes all type parameter information from the bytecode. At runtime the JVM sees only raw types. Unbounded T becomes Object; bounded T extends Number becomes Number. The compiler inserts casts automatically.

Why is List&lt;Bus&gt; not a subtype of List&lt;Vehicle&gt; even though Bus extends Vehicle? Because generics are invariant. If List&lt;Bus&gt; were a subtype of List&lt;Vehicle&gt;, you could add a Car to what is actually a List&lt;Bus&gt; through the List&lt;Vehicle&gt; reference, causing a ClassCastException when the bus is retrieved. The compiler prevents the assignment entirely.

What is a raw type? A raw type is a generic class used without specifying a type argument, like Print instead of Print&lt;Integer&gt;. The compiler substitutes Object for all type parameters, which is equivalent to the pre generics approach. Raw types exist only for backward compatibility and should never be used in new code.

What is PECS? Producer Extends, Consumer Super. When a collection produces data you read from it, use ? extends. When a collection consumes data you write into it, use ? super.

Why can't you do new T() or new T[10]? Because of type erasure. At runtime, T is just Object and the JVM has no idea what actual type to construct. The runtime information needed to call the right constructor or enforce array element types simply does not exist.

When would you use a wildcard instead of a type parameter? Use a wildcard when you need a lower bound (? super T), since lower bound type parameters do not exist. Use a wildcard when a single unknown type is enough and you do not need the constraint that multiple parameters must be the same type. Use a type parameter when you need to enforce that two or more parameters are identical, or when you need multiple named type variables.


The One Paragraph Summary

Generics let you write classes and methods that work with a type you specify at use time rather than hardcode at definition time. The compiler uses the type information to catch errors early and to generate the casts that would otherwise be your problem. At runtime, the JVM sees none of this: type erasure removes every type parameter from the bytecode, replacing unbounded parameters with Object and bounded ones with the bound class. This is why you cannot use instanceof with generic types, cannot create generic arrays, and cannot instantiate T directly. Wildcards restore the flexibility that invariance takes away: ? extends for reading from a family of types, ? super for writing into a family of types, and ? alone when any type will do. Understanding why each limitation exists, and what erasure actually does to your code, is the mark of a developer who truly understands generics rather than just using them by habit.