Compilers

Java Generics

Type parameters, type erasure, wildcards, and generic methods

Why generics exist

Before Java 5, collections had no type parameters. A List was just a list of Object, and every retrieval required a manual downcast:

// Pre-Java-5: raw types and downcasting
List listOld = new ArrayList();
listOld.add("hello");

for (Iterator it = listOld.iterator(); it.hasNext(); ) {
    Object o = it.next();
    String str = (String) o;   // manual downcast required
    System.out.println(str.length());
}

Java 5 introduced generics – type parameters for classes, interfaces, and methods – to eliminate these casts and provide compile-time type safety:

// Java 5+: parameterized types
List<String> strings = new ArrayList<>();
for (String s : strings) {
    System.out.println(s.length());  // no cast needed
}

Declaring generic classes

A generic class declares one or more type parameters inside angle brackets. The convention is a single uppercase letter:

Letter Convention
T General type
E Element / component type (collections)
K Key type (maps)
V Value type (maps)
public class Container<T> {
    private T field;

    public T getField() { return field; }
    public void setField(T field) { this.field = field; }
}

A parameterized instance is a generic class bound to a specific type:

Container<String> cs = new Container<>();
cs.setField("hello");         // setField takes String
String s = cs.getField();     // getField returns String

Container<Integer> ci = new Container<>();
ci.setField(42);              // setField takes Integer
Integer n = ci.getField();    // getField returns Integer

The compiler substitutes T with the bound type – setField on a Container<String> takes a String, getField returns a String. The same class works for any reference type without casting.


Type erasure

Generic type information exists only at compile time. At runtime, it is erased – a List<String> and a List<Integer> are the same class:

List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();

System.out.println(strings.getClass() == ints.getClass());  // true

Both are just ArrayList at runtime. You cannot ask a list what its parameterized type is.

Arrays are different – they retain their component type at runtime:

String[] strArr = new String[10];
Integer[] intArr = new Integer[10];

System.out.println(strArr.getClass() == intArr.getClass());  // false
  Compile time Runtime
Arrays Component type known Component type preserved
Generics Type parameter known Type parameter erased

Because of erasure, you cannot write code like return T.class inside a generic class – T does not exist at runtime. This is a well-known limitation of Java generics.


Type safety with generics

The compiler uses type parameters to catch errors at compile time:

Container<String> cs = new Container<>();
cs.setField(10);      // COMPILE ERROR: int is not String

Container<Integer> ci = new Container<>();
ci.setField("demo");  // COMPILE ERROR: String is not Integer

Even though both use the same Container class, the parameterized type prevents putting the wrong value in.


Primitives and autoboxing

You cannot parameterize on primitive types:

Container<int> c = new Container<>();  // COMPILE ERROR

Only reference types (objects on the heap) are allowed. Use the wrapper classes instead:

Primitive Wrapper
int Integer
boolean Boolean
double Double

Autoboxing automatically wraps primitives when assigned to a wrapper type:

Container<Integer> ci = new Container<>();
ci.setField(42);          // autoboxes int -> Integer
int val = ci.getField();  // auto-unboxes Integer -> int

Collections

The primary use case for generics is the Java Collections Framework:

Interface Type parameters Example
List<E> Element type List<String>
Set<E> Element type Set<Integer>
Map<K, V> Key type, value type Map<String, Integer>

Map demonstrates multiple type parameters – its put method takes (K key, V value) and operations are type-safe on both the key and value sides:

Map<String, Integer> ages = new HashMap<>();
ages.put("Alice", 30);    // K=String, V=Integer
Integer age = ages.get("Alice");

You can have as many type parameters as needed (comma-separated), though two is the practical maximum in most codebases.


Generics and assignability

Java arrays are covariant on their component type – String[] is assignable to Object[]. This creates a type hole:

Object[] objects = new String[10];  // legal (covariant)
objects[0] = 10;                    // compiles, but ArrayStoreException at runtime

Java generics do not allow this. List<String> is NOT assignable to List<Object>:

List<Object> objects = new ArrayList<String>();  // COMPILE ERROR

Why? If this were allowed, you could break type safety:

// If this were legal...
List<Object> objects = someListOfStrings;
objects.add(10);  // would put an Integer into a List<String>

This is the same hole that exists in CatScript’s list type and Java arrays, but the generics designers chose to close it.


Wildcards

The lack of covariance creates a practical problem:

class MyRunnable implements Runnable {
    public void run() { System.out.println("running"); }
}

void runAll(List<Runnable> runnables) {
    for (Runnable r : runnables) { r.run(); }
}

List<MyRunnable> mine = new ArrayList<>();
runAll(mine);  // COMPILE ERROR: List<MyRunnable> != List<Runnable>

Wildcards solve this with bounded type constraints using ?:

void runAll(List<? extends Runnable> runnables) {
    for (Runnable r : runnables) { r.run(); }  // OK: ? is at least Runnable
}

? extends Runnable means “some unknown type that extends Runnable.” You can read from the list (as Runnable), but you cannot write to it:

void runAll(List<? extends Runnable> runnables) {
    runnables.add(new MyRunnable());  // COMPILE ERROR
}

This is because add takes the parameterized type, which is ? – and nothing is assignable to a wildcard type. This restriction makes wildcard types read-only, preserving type safety.

Wildcard Meaning Use case
? extends T Unknown type that is a subtype of T Reading from a collection
? super T Unknown type that is a supertype of T Writing to a collection (rare)

You can also apply constraints to class-level type parameters:

public class Pair<T, V extends T> { ... }
// Valid: Pair<List, ArrayList>, Pair<Object, Integer>

Generic methods

Type parameters can also be declared on individual methods rather than on the class:

public static <T> T pickOne(T a, T b) {
    return Math.random() > 0.5 ? a : b;
}

The <T> before the return type declares a method-level type variable. The compiler infers T from the arguments:

String s = pickOne("hello", "world");  // T inferred as String
Integer n = pickOne(1, 2);             // T inferred as Integer

When the arguments have different types, the compiler performs type unification – it finds the highest common supertype:

Object result = pickOne("hello", 42);  // T unifies to Object

String and Integer both extend Object, so T resolves to Object.

This is the same kind of type unification CatScript performs for list literals:

[1]            // list<int>
[1, "foo"]     // list<object> -- unifies int and string

Practical advice

Use generics sparingly. Their primary value is in collection typesList<String>, Map<K,V>, and similar containers. Outside of collections, generics tend to spiral into complexity that makes code harder to read and maintain.

Generics are very dangerous. Keep them in the spots where they add the most value, which is almost always container classes.

If a codebase uses generics heavily outside of collections, it is likely overengineered. The wildcard system (? extends, ? super, nested constraints) can make types nearly impossible to reason about.

For a cleaner implementation of generics, C# is worth studying – it avoids erasure by preserving type information at runtime (called reification), which eliminates many of the awkward limitations Java developers deal with.


See also: Smuggling Generic Types into the Runtime – how libraries like Jackson and Guava use type tokens to work around erasure.