Generics in Java
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.
