Generics

Generic Restrictions

15 min Lesson 8 of 13

Generic Restrictions

Generics make Java code reusable and type-safe, but they come with a set of hard restrictions that trip up even experienced developers. Understanding why these restrictions exist — rooted in the JVM feature called type erasure — makes them much easier to remember and work around.

Quick recap: type erasure

At compile time the Java compiler checks all generic types. When it produces bytecode it erases the type parameters, replacing them with either Object or the first bound. At runtime a List<String> and a List<Integer> are both just List — the JVM sees no difference. This erasure is the root cause of almost every generic restriction below.

Restriction 1 — No primitive type arguments

Type parameters must be reference types. You cannot use int, double, boolean, or any other primitive as a type argument.

// DOES NOT COMPILE List<int> numbers = new ArrayList<>(); // Correct — use the wrapper class instead List<Integer> numbers = new ArrayList<>(); numbers.add(42); // autoboxing: int -> Integer automatically int n = numbers.get(0); // unboxing: Integer -> int automatically

Why? After erasure the slot in the list holds an Object reference. Primitives are not objects; they cannot be stored as Object. Wrapper types (Integer, Double, Long, etc.) are full objects, so they work fine. Java's autoboxing converts back and forth transparently in most situations.

Performance note: Autoboxing has a small overhead. If you need a high-performance collection of primitives (millions of elements in a tight loop), consider specialised libraries like Eclipse Collections or plain arrays. For everyday use, wrapper types are perfectly fine.

Restriction 2 — Cannot create arrays of parameterised types

You cannot create an array whose component type is a parameterised generic type.

// DOES NOT COMPILE List<String>[] table = new List<String>[10]; // Workaround 1 — use a raw type (triggers an unchecked warning) @SuppressWarnings("unchecked") List<String>[] table = new List[10]; // Workaround 2 — use a List of Lists instead (preferred) List<List<String>> table = new ArrayList<>();

Why? Java arrays are covariant: a String[] is a Object[]. Arrays also carry their component type at runtime and do an assignability check on every write (array store check). Generics are erased, so List<String>[] and List<Integer>[] both become List[] at runtime. Combining covariant arrays with erased types would allow you to silently store the wrong type — the compiler blocks this to prevent a heap pollution bug that would only surface later as a mysterious ClassCastException.

Heap pollution occurs when a variable of a parameterised type refers to an object that is not of that type. The raw-type workaround above can cause it; the @SuppressWarnings("unchecked") annotation is your acknowledgement that you have checked the code manually. Prefer List<List<String>> to avoid the problem entirely.

Restriction 3 — Cannot use instanceof with parameterised types

Because type parameters are erased, the JVM has no information about them at runtime. Therefore instanceof with a parameterised type is illegal.

Object obj = getSomeObject(); // DOES NOT COMPILE if (obj instanceof List<String>) { ... } // Correct — check the raw type, then cast carefully if (obj instanceof List<?> list) { // Java 16+ pattern variable // obj is some kind of List, but we cannot confirm <String> at runtime }

The wildcard form List<?> is allowed because it makes no claim about the type argument — it just says "some List". The parameterised form List<String> would promise a runtime check that the JVM cannot perform after erasure.

Restriction 4 — Cannot instantiate a type parameter directly

Writing new T() inside a generic class is illegal because after erasure the compiler does not know which constructor to call.

class Factory<T> { // DOES NOT COMPILE T create() { return new T(); } // Workaround — pass a Supplier or a Class<T> token T createWithSupplier(java.util.function.Supplier<T> supplier) { return supplier.get(); } } // Usage Factory<StringBuilder> f = new Factory<>(); StringBuilder sb = f.createWithSupplier(StringBuilder::new);

Restriction 5 — Static members cannot use class type parameters

Static fields and static methods belong to the class itself, not to any particular parameterised instance. Using the class-level type parameter in a static context is therefore illegal.

class Container<T> { // DOES NOT COMPILE — T is an instance-level parameter, not a class-level constant private static T defaultValue; // DOES NOT COMPILE public static T getDefault() { return defaultValue; } // Correct — declare a separate type parameter on the static method public static <U> U identity(U value) { return value; } }

Putting it all together — a clean generic utility class

import java.util.ArrayList; import java.util.List; import java.util.function.Supplier; public class GenericBox<T> { private final List<T> items = new ArrayList<>(); // List of List is fine public void add(T item) { items.add(item); } public T get(int index) { return items.get(index); } // Static helper uses its own <U>, not the class T public static <U> GenericBox<U> filled(Supplier<U> supplier, int count) { GenericBox<U> box = new GenericBox<>(); for (int i = 0; i < count; i++) { box.add(supplier.get()); } return box; } }

Summary

  • No primitives as type arguments — use wrapper types; autoboxing handles the conversion.
  • No new T[] — use a List<T> or pass a raw array with an unchecked cast.
  • No instanceof List<String> — check against List<?> (the raw or wildcard form).
  • No new T() — pass a Supplier<T> or Class<T> instead.
  • No static use of the class type parameter — give the static method its own type parameter.

Every one of these restrictions traces back to the same cause: type erasure. Once you internalise that the JVM sees only raw types at runtime, the rules stop feeling arbitrary and start making perfect sense.