Java Generics - Generic Classes, Wildcards and Type Erasure
1. What are Generics?
Generics, introduced in Java 5, allow you to
write classes, interfaces, and methods that work with any type
while providing compile-time type safety. Before
generics, developers used raw types — collections
and containers operated on Object,
requiring explicit casts everywhere.
The Problem Before Generics
// Pre-generics: raw type List stores Object
List names = new ArrayList();
names.add("Alice");
names.add("Bob");
names.add(42); // accidentally added an Integer — no compile error!
// Explicit cast needed — fails at RUNTIME if type is wrong
String first = (String) names.get(2); // ClassCastException at runtime!
class java.lang.Integer cannot be cast to class java.lang.String
The Solution: Generics
// With generics: compiler enforces type correctness
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
// names.add(42); // COMPILE ERROR — Integer is not a String
String first = names.get(0); // no cast needed, always safe
Key benefit: Errors are caught at compile time, not at runtime. This makes your code safer, eliminates manual casting, and improves readability.
2. Generic Classes
A generic class declares one or more
type parameters in angle brackets after the class name.
By convention, T stands for "Type".
The type parameter acts as a placeholder that gets replaced with a
real type when the class is instantiated.
// Generic class with type parameter T
public class Box<T> {
private T value;
public Box(T value) {
this.value = value;
}
public T getValue() {
return value;
}
public void setValue(T value) {
this.value = value;
}
@Override
public String toString() {
return "Box[" + value + "]";
}
}
public class Main {
public static void main(String[] args) {
Box<String> stringBox = new Box<>("Hello Generics");
Box<Integer> intBox = new Box<>(100);
System.out.println(stringBox.getValue()); // no cast needed
System.out.println(intBox.getValue() * 2);
// stringBox.setValue(123); // COMPILE ERROR — must be String
}
}
200
3. Generic Methods
A method can declare its own type parameters, independent of any class-level type parameters. The type parameter list appears before the return type.
public class GenericUtils {
// T is the method's own type parameter
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.print(element + " ");
}
System.out.println();
}
// Returns the larger of two Comparable values
public static <T extends Comparable<T>> T max(T a, T b) {
return (a.compareTo(b) >= 0) ? a : b;
}
public static void main(String[] args) {
Integer[] nums = {1, 2, 3, 4, 5};
String[] strs = {"banana", "apple", "cherry"};
printArray(nums); // T inferred as Integer
printArray(strs); // T inferred as String
System.out.println(max(10, 20)); // 20
System.out.println(max("cat", "dog")); // dog
}
}
banana apple cherry
20
dog
4. Generic Interfaces
Interfaces can also be parameterized with type parameters. Implementing classes either provide a concrete type or pass the type parameter through.
// Generic interface with two type parameters
public interface Pair<K, V> {
K getKey();
V getValue();
}
// Concrete implementation
public class OrderedPair<K, V> implements Pair<K, V> {
private final K key;
private final V value;
public OrderedPair(K key, V value) {
this.key = key;
this.value = value;
}
@Override public K getKey() { return key; }
@Override public V getValue() { return value; }
@Override
public String toString() {
return "(" + key + ", " + value + ")";
}
}
public class Main {
public static void main(String[] args) {
Pair<String, Integer> p1 = new OrderedPair<>("Alice", 30);
Pair<String, String> p2 = new OrderedPair<>("country", "Java");
System.out.println(p1);
System.out.println(p2);
}
}
(country, Java)
5. Bounded Type Parameters
Bounds restrict the types that can be used as type arguments. Use
extends for upper bounds (works for
both classes and interfaces).
Upper Bound: <T extends Number>
Restricts T to Number or any of its
subclasses (Integer,
Double, etc.), allowing access to
Number's methods.
public class Stats<T extends Number> {
private T[] data;
public Stats(T[] data) { this.data = data; }
public double average() {
double sum = 0;
for (T val : data) {
sum += val.doubleValue(); // doubleValue() is from Number
}
return sum / data.length;
}
}
public class Main {
public static void main(String[] args) {
Integer[] ints = {2, 4, 6, 8, 10};
Double[] doubles = {1.5, 2.5, 3.5};
System.out.println(new Stats<>(ints).average()); // 6.0
System.out.println(new Stats<>(doubles).average()); // 2.5
// Stats<String> s = new Stats<>(...); // COMPILE ERROR
}
}
2.5
Multiple Bounds
A type parameter can have multiple bounds using
&. The class bound (if any) must
come first.
// T must extend Comparable AND implement Serializable
public <T extends Comparable<T> & java.io.Serializable> T clamp(T val, T min, T max) {
if (val.compareTo(min) < 0) return min;
if (val.compareTo(max) > 0) return max;
return val;
}
6. Wildcards
The wildcard
? represents an unknown type. It is
used in method parameters (not class declarations) to increase
flexibility when you need to accept a range of parameterized
types.
| Wildcard | Name | Meaning | Use When |
|---|---|---|---|
<?> |
Unbounded | Any type |
Only using Object methods;
read-only iteration
|
<? extends T> |
Upper bounded | T or any subtype of T | Reading/consuming data (producer) |
<? super T> |
Lower bounded | T or any supertype of T | Writing/supplying data (consumer) |
Unbounded Wildcard
public static void printList(List<?> list) {
for (Object item : list) {
System.out.print(item + " ");
}
System.out.println();
}
printList(List.of(1, 2, 3));
printList(List.of("a", "b", "c"));
a b c
Upper Bounded Wildcard
// Accepts List<Integer>, List<Double>, List<Number>, etc.
public static double sumOfList(List<? extends Number> list) {
double sum = 0;
for (Number n : list) sum += n.doubleValue();
return sum;
}
System.out.println(sumOfList(List.of(1, 2, 3))); // 6.0
System.out.println(sumOfList(List.of(1.5, 2.5, 3.0))); // 7.0
7.0
Lower Bounded Wildcard
// Accepts List<Integer>, List<Number>, List<Object>
public static void addNumbers(List<? super Integer> list) {
for (int i = 1; i <= 5; i++) list.add(i);
}
List<Number> numbers = new ArrayList<>();
addNumbers(numbers);
System.out.println(numbers);
PECS Principle: "Producer Extends, Consumer
Super." If a parameterized type produces T values (you
read from it), use
<? extends T>. If it
consumes T values (you write to it), use
<? super T>.
7. Type Erasure
Java generics are a compile-time mechanism only.
The Java compiler uses type information to perform type checks and
insert casts, then erases all type parameters
from the bytecode. At runtime, a
List<String> and a
List<Integer> are both just
List.
// Source code (what you write)
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0);
// After type erasure (what the JVM sees)
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // compiler inserts this cast
Implications of type erasure:
-
You cannot use
instanceofwith a parameterized type:obj instanceof List<String>is a compile error. -
You cannot create instances of a type parameter:
new T()is not allowed. -
You cannot create generic arrays:
new T[10]is not allowed directly. - Static fields of a generic class cannot use the type parameter.
// This is legal — checks erased type
if (list instanceof List<?>) { ... }
// This is NOT legal — type erased at runtime
// if (list instanceof List<String>) { ... } // COMPILE ERROR
Heap pollution: Mixing raw types and generic
types can lead to unchecked warnings and unexpected
ClassCastException at runtime
because the compiler cannot guarantee type safety across
raw-type boundaries.
8. Generic Collections
The Java Collections Framework was retrofitted with generics in Java 5. Always use parameterized collections — never use raw types in new code.
import java.util.*;
public class GenericCollections {
public static void main(String[] args) {
// Typed List — compiler prevents adding wrong types
List<String> languages = new ArrayList<>();
languages.add("Java");
languages.add("Kotlin");
languages.add("Scala");
// languages.add(42); // COMPILE ERROR
for (String lang : languages) {
System.out.println(lang.toUpperCase()); // no cast needed
}
// Typed Map
Map<String, Integer> scores = new HashMap<>();
scores.put("Alice", 95);
scores.put("Bob", 87);
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
}
}
KOTLIN
SCALA
Alice: 95
Bob: 87
9. Multiple Type Parameters
A generic class or method can declare multiple type parameters,
separated by commas. The standard library's
Map.Entry<K, V> is a classic
example.
// Generic Pair class with two type parameters
public class Pair<K, V> {
private final K key;
private final V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() { return key; }
public V getValue() { return value; }
public static <K, V> Pair<K, V> of(K k, V v) {
return new Pair<>(k, v);
}
@Override
public String toString() {
return key + " -> " + value;
}
}
public class Main {
public static void main(String[] args) {
Pair<String, Integer> nameAge = Pair.of("Alice", 30);
Pair<String, Boolean> feature = Pair.of("darkMode", true);
Pair<Integer, Double> ratio = Pair.of(1, 1.618);
System.out.println(nameAge);
System.out.println(feature);
System.out.println(ratio);
// Map.Entry is Java's built-in two-parameter generic
Map<String, List<Integer>> groupedScores = new HashMap<>();
groupedScores.put("math", List.of(90, 85, 92));
for (Map.Entry<String, List<Integer>> e : groupedScores.entrySet()) {
System.out.println(e.getKey() + ": " + e.getValue());
}
}
}
darkMode -> true
1 -> 1.618
math: [90, 85, 92]
10. Common Conventions
Java uses single uppercase letters as type parameter names by convention. Following these conventions makes generic code instantly recognizable to other Java developers.
| Letter | Stands For | Typical Usage |
|---|---|---|
T |
Type |
General-purpose type parameter:
Box<T>,
Comparable<T>
|
E |
Element |
Elements in a collection:
List<E>,
Set<E>
|
K |
Key |
Map key type: Map<K, V>
|
V |
Value |
Map value type: Map<K, V>,
Cache<K, V>
|
N |
Number |
Numeric type parameters:
Stats<N extends Number>
|
R |
Result / Return |
Function return types:
Function<T, R>
|
S, U, W |
Secondary types | Second, third, fourth type params when T is already used |
// Convention examples from the standard library interface Comparable<T> // T = Type interface Collection<E> // E = Element interface Map<K, V> // K = Key, V = Value interface Function<T, R> // T = input Type, R = Return type interface BiFunction<T, U, R> // T, U = inputs, R = return
Good practice: Always parameterize your
collections and containers. Enable the compiler warning
-Xlint:unchecked (or the equivalent
IDE warning) to catch any accidental raw-type usage in your
codebase.