Introduction to Generics
Generics are one of the most powerful features introduced in Java 5. They enable you to write code that works with different types while providing compile-time type safety. Before generics, developers had to use raw types and casts, leading to runtime errors that could have been caught at compile time.
The core idea behind generics is simple: instead of writing code that works with a specific type like String or Integer, you write code that works with a type parameter—a placeholder that gets replaced with an actual type when the code is used. This allows you to write a single class or method that works correctly and safely with many different types.
Generics are everywhere in modern Java. The Collections Framework, Stream API, Optional class, and countless libraries all rely heavily on generics. Understanding them deeply is essential for reading and writing professional Java code.
- Type Safety - Catch type errors at compile time, not runtime
- Elimination of Casts - No more explicit type casting when retrieving elements
- Code Reusability - Write once, use with any type
- Self-Documenting Code - Types declare what data structures hold
- Better IDE Support - Auto-completion and refactoring work correctly
- API Design - Express constraints and relationships between types
The Problem Generics Solve
To understand why generics exist, let's look at what Java code looked like before Java 5.
Life Before Generics (The Dark Ages)
/*
* Before Java 5: Collections stored Objects
* This led to runtime errors and required casting
*/
// Creating a list the old way (raw type)
List names = new ArrayList();
// You could add anything - no type checking!
names.add("Alice");
names.add("Bob");
names.add(123); // Oops! Added an Integer to a "names" list
names.add(new Date()); // Oops! Added a Date too
// When retrieving, you had to cast
String first = (String) names.get(0); // Works
String third = (String) names.get(2); // ClassCastException at RUNTIME!
// The compiler couldn't help you catch this bug
// You'd only discover it when your program crashed
With Generics (The Modern Way)
/*
* With Generics: Type safety at compile time
*/
// The type parameter <String> tells the compiler what this list holds
List<String> names = new ArrayList<String>();
// Now the compiler enforces the type
names.add("Alice"); // OK
names.add("Bob"); // OK
// names.add(123); // COMPILE ERROR! Integer is not a String
// names.add(new Date()); // COMPILE ERROR!
// No casting needed - the compiler knows it's a String
String first = names.get(0); // Clean and safe
// Java 7+: Diamond operator infers the type
List<String> modernList = new ArrayList<>(); // Type inferred
/*
* Visual comparison of the difference:
*
* WITHOUT GENERICS WITH GENERICS
* ───────────────── ─────────────────
* List list = ... List<String> list = ...
* │ │
* ▼ ▼
* ┌─────────────┐ ┌─────────────┐
* │ Object │ ◄─ stores │ String │ ◄─ stores
* │ Object │ anything │ String │ only Strings
* │ Object │ │ String │
* └─────────────┘ └─────────────┘
* │ │
* ▼ ▼
* (String) list.get(0) list.get(0)
* ↑ Cast required ↑ No cast needed
* ↑ Can fail at runtime ↑ Always safe
*/
Generic Classes
A generic class is a class that can work with any type. You define type parameters in angle brackets after the class name.
Defining a Generic Class
/**
* A simple container that holds a value of any type.
* T is a type parameter - it's a placeholder for an actual type.
*
* Common type parameter naming conventions:
* T - Type (general purpose)
* E - Element (used in collections)
* K - Key (used in maps)
* V - Value (used in maps)
* N - Number
* S, U, V - 2nd, 3rd, 4th types
*/
public class Box<T> {
private T content;
public void set(T content) {
this.content = content;
}
public T get() {
return content;
}
public boolean isEmpty() {
return content == null;
}
}
// Usage: T is replaced with actual types
Box<String> stringBox = new Box<>();
stringBox.set("Hello, Generics!");
String message = stringBox.get(); // No cast needed
Box<Integer> intBox = new Box<>();
intBox.set(42);
int number = intBox.get(); // Auto-unboxing
Box<List<String>> listBox = new Box<>(); // Nested generics work too!
Multiple Type Parameters
/**
* A class with multiple type parameters.
* Think of Map<K, V> - it has 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; }
@Override
public String toString() {
return "(" + key + ", " + value + ")";
}
}
// Usage with different type combinations
Pair<String, Integer> nameAge = new Pair<>("Alice", 30);
Pair<Integer, Boolean> idActive = new Pair<>(1001, true);
Pair<String, List<String>> personFriends = new Pair<>("Bob", List.of("Alice", "Charlie"));
String name = nameAge.getKey(); // String
Integer age = nameAge.getValue(); // Integer
Real-World Example: Result Type
/**
* A Result type that can hold either a success value or an error.
* Common pattern in functional programming and modern APIs.
*/
public class Result<T, E> {
private final T value;
private final E error;
private final boolean success;
private Result(T value, E error, boolean success) {
this.value = value;
this.error = error;
this.success = success;
}
public static <T, E> Result<T, E> success(T value) {
return new Result<>(value, null, true);
}
public static <T, E> Result<T, E> failure(E error) {
return new Result<>(null, error, false);
}
public boolean isSuccess() { return success; }
public T getValue() { return value; }
public E getError() { return error; }
}
// Usage
public Result<User, String> findUser(int id) {
User user = database.findById(id);
if (user != null) {
return Result.success(user);
}
return Result.failure("User not found: " + id);
}
Result<User, String> result = findUser(123);
if (result.isSuccess()) {
User user = result.getValue();
// process user
} else {
String error = result.getError();
// handle error
}
Generic Methods
Generic methods have their own type parameters, independent of any class-level type parameters. They can be defined in both generic and non-generic classes.
Defining Generic Methods
public class Utils {
/**
* A generic method - note the <T> before the return type.
* This declares a new type parameter T just for this method.
*/
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.print(element + " ");
}
System.out.println();
}
/**
* Generic method that returns a value of the type parameter.
*/
public static <T> T getFirst(List<T> list) {
if (list.isEmpty()) {
return null;
}
return list.get(0);
}
/**
* Generic method with multiple type parameters.
*/
public static <K, V> Map<K, V> createMap(K key, V value) {
Map<K, V> map = new HashMap<>();
map.put(key, value);
return map;
}
}
// Usage - type inference usually figures out the types
Integer[] numbers = {1, 2, 3, 4, 5};
String[] names = {"Alice", "Bob", "Charlie"};
Utils.printArray(numbers); // T is inferred as Integer
Utils.printArray(names); // T is inferred as String
List<String> nameList = List.of("Alice", "Bob");
String first = Utils.getFirst(nameList); // Returns String
Map<String, Integer> map = Utils.createMap("age", 30);
// Explicit type specification (rarely needed)
Utils.<String>printArray(new String[]{"a", "b"});
Generic Methods in Generic Classes
public class Container<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
/**
* This method has its OWN type parameter U.
* U is independent of the class's T.
*/
public <U> void inspect(U element) {
System.out.println("Type of T: " + item.getClass().getName());
System.out.println("Type of U: " + element.getClass().getName());
}
/**
* Method that transforms T to a different type U.
*/
public <U> Container<U> map(Function<T, U> mapper) {
Container<U> result = new Container<>();
result.setItem(mapper.apply(item));
return result;
}
}
// Usage
Container<String> stringContainer = new Container<>();
stringContainer.setItem("Hello");
stringContainer.inspect(42); // U is Integer, T is String
Container<Integer> lengthContainer = stringContainer.map(String::length);
System.out.println(lengthContainer.getItem()); // 5
Bounded Type Parameters
Sometimes you need to restrict what types can be used as type arguments. Bounded type parameters let you specify that a type must be a subtype of a particular class or implement certain interfaces.
Upper Bounds (extends)
/**
* T must be Number or a subclass of Number.
* This gives us access to Number's methods.
*/
public class NumericBox<T extends Number> {
private T value;
public NumericBox(T value) {
this.value = value;
}
// Because T extends Number, we can call Number methods
public double doubleValue() {
return value.doubleValue();
}
public double square() {
double d = value.doubleValue();
return d * d;
}
public boolean isPositive() {
return value.doubleValue() > 0;
}
}
// Valid: Integer, Double, Long, etc. all extend Number
NumericBox<Integer> intBox = new NumericBox<>(5);
NumericBox<Double> doubleBox = new NumericBox<>(3.14);
NumericBox<BigDecimal> bigBox = new NumericBox<>(new BigDecimal("100.50"));
System.out.println(intBox.square()); // 25.0
System.out.println(doubleBox.square()); // 9.8596
// Invalid: String doesn't extend Number
// NumericBox<String> stringBox = new NumericBox<>("hello"); // COMPILE ERROR!
Multiple Bounds
/**
* T must extend Number AND implement Comparable.
* Multiple bounds use & (ampersand) separator.
* Class bound must come first, then interface bounds.
*/
public class SortableNumericBox<T extends Number & Comparable<T>> {
private T value;
public SortableNumericBox(T value) {
this.value = value;
}
// Can use Number methods
public double doubleValue() {
return value.doubleValue();
}
// Can use Comparable methods
public boolean isGreaterThan(T other) {
return value.compareTo(other) > 0;
}
}
// Integer extends Number and implements Comparable<Integer>
SortableNumericBox<Integer> box = new SortableNumericBox<>(10);
System.out.println(box.isGreaterThan(5)); // true
Bounded Type Parameters in Methods
public class MathUtils {
/**
* Find the maximum element in an array.
* T must implement Comparable so we can compare elements.
*/
public static <T extends Comparable<T>> T max(T[] array) {
if (array == null || array.length == 0) {
return null;
}
T max = array[0];
for (int i = 1; i < array.length; i++) {
if (array[i].compareTo(max) > 0) {
max = array[i];
}
}
return max;
}
/**
* Sum all numbers in a list.
* T must extend Number.
*/
public static <T extends Number> double sum(List<T> numbers) {
double total = 0;
for (T num : numbers) {
total += num.doubleValue();
}
return total;
}
}
// Usage
Integer[] nums = {3, 1, 4, 1, 5, 9};
Integer max = MathUtils.max(nums); // 9
String[] words = {"apple", "zebra", "banana"};
String maxWord = MathUtils.max(words); // "zebra"
List<Integer> integers = List.of(1, 2, 3);
List<Double> doubles = List.of(1.5, 2.5, 3.5);
double intSum = MathUtils.sum(integers); // 6.0
double doubleSum = MathUtils.sum(doubles); // 7.5
Wildcards
Wildcards (?) represent unknown types. They're especially useful in method parameters when you want to accept collections of different types.
Unbounded Wildcard (?)
/**
* Unbounded wildcard: accepts List of ANY type.
* Use when you only need methods from Object class.
*/
public static void printList(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
}
// Can pass any List
List<String> strings = List.of("A", "B");
List<Integer> integers = List.of(1, 2);
List<Object> objects = List.of("Hello", 42);
printList(strings); // Works
printList(integers); // Works
printList(objects); // Works
Upper Bounded Wildcard (? extends)
/**
* Upper bounded: accepts Number or any subtype.
* Use when you need to READ from the collection.
* "Producer Extends" - the collection produces elements.
*/
public static double sumOfList(List<? extends Number> list) {
double sum = 0;
for (Number num : list) {
sum += num.doubleValue();
}
return sum;
}
// Can pass List of Number or any subclass
List<Integer> integers = List.of(1, 2, 3);
List<Double> doubles = List.of(1.5, 2.5);
List<Number> numbers = List.of(1, 2.5, 3L);
double sum1 = sumOfList(integers); // 6.0
double sum2 = sumOfList(doubles); // 4.0
double sum3 = sumOfList(numbers); // 6.5
// CANNOT add to a List<? extends Number> (except null)
public static void cannotAdd(List<? extends Number> list) {
// list.add(1); // COMPILE ERROR!
// list.add(1.0); // COMPILE ERROR!
// Compiler doesn't know the actual type
}
Lower Bounded Wildcard (? super)
/**
* Lower bounded: accepts Integer or any supertype (Number, Object).
* Use when you need to WRITE to the collection.
* "Consumer Super" - the collection consumes elements.
*/
public static void addIntegers(List<? super Integer> list) {
list.add(1); // OK
list.add(2); // OK
list.add(3); // OK
}
// Can pass List of Integer or any supertype
List<Integer> integers = new ArrayList<>();
List<Number> numbers = new ArrayList<>();
List<Object> objects = new ArrayList<>();
addIntegers(integers); // Works
addIntegers(numbers); // Works
addIntegers(objects); // Works
// CANNOT reliably read from List<? super Integer>
public static void cannotRead(List<? super Integer> list) {
// Integer i = list.get(0); // COMPILE ERROR!
Object o = list.get(0); // OK, but only as Object
}
PECS Principle: Producer Extends, Consumer Super
/*
* PECS is the key rule for wildcards:
*
* - Use "? extends T" when you GET values from a structure (producer)
* - Use "? super T" when you PUT values into a structure (consumer)
* - Use neither (just T) when you do both
*
* Example: Collections.copy(dest, src)
*/
public static <T> void copy(
List<? super T> dest, // Consumer - we PUT into dest
List<? extends T> src) { // Producer - we GET from src
for (T item : src) {
dest.add(item);
}
}
// This works because:
// - We read from src (? extends)
// - We write to dest (? super)
List<Number> dest = new ArrayList<>();
List<Integer> src = List.of(1, 2, 3);
copy(dest, src); // Copies Integer elements into Number list
Type Erasure
Java implements generics through type erasure, meaning generic type information is removed at compile time. This has important implications.
/*
* TYPE ERASURE - What happens at compile time:
*
* YOUR CODE: AFTER ERASURE:
* ────────── ──────────────
* List<String> list = ... List list = ...
* String s = list.get(0); String s = (String) list.get(0);
*
* Box<Integer> box = ... Box box = ...
* Integer i = box.get(); Integer i = (Integer) box.get();
*
* The compiler:
* 1. Checks types at compile time
* 2. Removes generic type info
* 3. Inserts casts where needed
*/
// Because of type erasure, these are the SAME class at runtime:
List<String> stringList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
System.out.println(stringList.getClass() == intList.getClass()); // true!
System.out.println(stringList.getClass().getName()); // java.util.ArrayList
// You CANNOT do these because of type erasure:
// 1. Create generic arrays
// T[] array = new T[10]; // COMPILE ERROR!
// 2. Use instanceof with generic types
// if (obj instanceof List<String>) {} // COMPILE ERROR!
if (obj instanceof List<?>) {} // OK with wildcard
// 3. Create instances of type parameters
// T item = new T(); // COMPILE ERROR!
// Workaround: Pass a Class object or use a factory
public <T> T create(Class<T> clazz) throws Exception {
return clazz.getDeclaredConstructor().newInstance();
}
Generic Interfaces
/**
* A generic interface.
*/
public interface Repository<T, ID> {
T findById(ID id);
List<T> findAll();
T save(T entity);
void delete(T entity);
}
// Implementation specifies the actual types
public class UserRepository implements Repository<User, Long> {
@Override
public User findById(Long id) {
// implementation
return null;
}
@Override
public List<User> findAll() {
// implementation
return new ArrayList<>();
}
@Override
public User save(User user) {
// implementation
return user;
}
@Override
public void delete(User user) {
// implementation
}
}
// Usage
Repository<User, Long> userRepo = new UserRepository();
User user = userRepo.findById(1L);
Common Pitfalls
// BAD: Raw type - loses all type safety
List rawList = new ArrayList();
rawList.add("string");
rawList.add(123); // No compile error, but dangerous!
// GOOD: Always specify type parameters
List<String> typedList = new ArrayList<>();
// typedList.add(123); // Compile error - caught early!
// BAD: Cannot create generic arrays directly
// List<String>[] array = new ArrayList<String>[10]; // COMPILE ERROR!
// GOOD: Use List of Lists instead
List<List<String>> listOfLists = new ArrayList<>();
// Or use @SuppressWarnings with caution
@SuppressWarnings("unchecked")
List<String>[] array = (List<String>[]) new List<?>[10];
// BAD: This doesn't work as expected
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
// These are the same class at runtime!
System.out.println(strings.getClass() == integers.getClass()); // true
// Cannot use instanceof with parameterized types
// if (obj instanceof List<String>) {} // COMPILE ERROR!
if (obj instanceof List<?>) {} // OK
// Remember PECS: Producer Extends, Consumer Super
// WRONG: Trying to add to "? extends"
public void wrong(List<? extends Number> list) {
// list.add(1); // COMPILE ERROR - can only read!
}
// RIGHT: Use "? super" when you need to add
public void right(List<? super Integer> list) {
list.add(1); // OK
}
Interview Questions
Q: What are generics and why were they introduced?
A: Generics allow type parameters in classes, interfaces, and methods. They were introduced in Java 5 to provide compile-time type safety and eliminate the need for casting when working with collections. They catch type errors at compile time instead of runtime.
Q: What is type erasure?
A: Type erasure is Java's implementation of generics where generic type information is removed at compile time. The compiler checks types, then erases them and inserts casts. This means List<String> and List<Integer> are the same class at runtime.
Q: What's the difference between <?>, <? extends T>, and <? super T>?
A: <?> is an unbounded wildcard accepting any type. <? extends T> is an upper bound accepting T or its subtypes (use for reading). <? super T> is a lower bound accepting T or its supertypes (use for writing). Remember PECS: Producer Extends, Consumer Super.
Q: Can you create an instance of a type parameter?
A: No, you cannot do new T() because of type erasure. The compiler doesn't know what T is at runtime. Workarounds include passing a Class<T> object or using a factory/supplier.
Q: Why can't you create a generic array like new T[10]?
A: Arrays are covariant and retain their type at runtime, while generics are invariant and use erasure. Creating generic arrays would break type safety because the array wouldn't know what type to enforce at runtime.
Q: What is a raw type and why should you avoid it?
A: A raw type is using a generic class without type parameters (e.g., List instead of List<String>). It disables type checking and exists only for backward compatibility. Always use parameterized types for type safety.
Q: What does <T extends Comparable<? super T>> mean?
A: This is a common pattern meaning T must implement Comparable for itself or a supertype. For example, if you have a class Apple extends Fruit and Fruit implements Comparable<Fruit>, Apple would satisfy this bound because it's comparable with its supertype Fruit.
Best Practices
- Always use parameterized types - Never use raw types like
List - Use meaningful type parameter names - T for type, E for element, K/V for key/value
- Prefer generic methods over wildcards when the type appears multiple times
- Follow PECS - Producer Extends, Consumer Super
- Use bounded type parameters when you need to call methods on the type
- Favor composition over inheritance with generic types
- Don't use raw types in new code - They exist only for backward compatibility
- Suppress warnings carefully - Only use @SuppressWarnings when you're certain it's safe
See Also
- Collections Framework - Heavily uses generics
- Lambda & Streams - Functional programming with generics
- Java Version History - Generics introduced in Java 5
- Design Patterns - Generic implementations of patterns