Skip to content

Why Do We Need Java Generics? (Practical Examples Explained)

1. The Problem

I was writing a simple Java program to store and retrieve strings from a list. Here’s what I wrote:

BeforeGenerics.java
import java.util.ArrayList;
import java.util.List;
public class BeforeGenerics {
public static void main(String[] args) {
List names = new ArrayList();
names.add("Alice");
names.add("Bob");
names.add("Charlie");
// Later, I want to get the first name
String firstName = (String) names.get(0);
System.out.println("First name: " + firstName);
}
}

The code compiles and runs fine. But then I accidentally added an integer to the same list:

AccidentalError.java
List names = new ArrayList();
names.add("Alice");
names.add(42); // Oops! I added an Integer by mistake
names.add("Bob");
String firstName = (String) names.get(0); // OK
String secondName = (String) names.get(1); // Runtime error!

When I ran this, I got:

Terminal window
Exception in thread "main" java.lang.ClassCastException:
class java.lang.Integer cannot be cast to class java.lang.String
at BeforeGenerics.main(BeforeGenerics.java:8)

The error happens at runtime, not compile time. I have to manually cast every object I retrieve, and there’s nothing stopping me from putting the wrong type into the list. This is exactly what Java Generics solves.

2. What Are Java Generics?

Java Generics let you write code that works with any type while catching type errors at compile-time instead of runtime.

The key insight is simple: Collections and containers don’t care what they’re holding, but you do. Generics let you tell the compiler what type your collection should hold, so it can check for you.

Here’s the same code using generics:

WithGenerics.java
import java.util.ArrayList;
import java.util.List;
public class WithGenerics {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.add(42); // Compile error! Won't even compile
String firstName = names.get(0); // No cast needed!
System.out.println("First name: " + firstName);
}
}

Now the compiler catches the error immediately:

Terminal window
error: incompatible types: int cannot be converted to String
names.add(42);
^

The benefits are clear:

  • Compile-time type checking: Errors are caught before your code runs
  • No casts needed: The compiler knows what type is in the list
  • Self-documenting code: List<String> clearly shows what the list contains

3. Generics in Action: Real Examples

3.1 Collections (The Most Common Use)

GenericsCollections.java
import java.util.*;
public class GenericsCollections {
public static void main(String[] args) {
// List of Strings
List<String> fruits = new ArrayList<>();
fruits.add("Apple");
fruits.add("Banana");
String fruit = fruits.get(0); // No cast needed
// Map with String keys and Integer values
Map<String, Integer> ages = new HashMap<>();
ages.put("Alice", 25);
ages.put("Bob", 30);
int aliceAge = ages.get("Alice"); // Returns Integer directly
// Set of custom objects
Set<Person> people = new HashSet<>();
people.add(new Person("Charlie", 28));
}
}
class Person {
String name;
int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
}

3.2 Generic Methods

You can also write methods that work with any type:

GenericMethods.java
public class GenericMethods {
// A generic method that swaps two elements in an array
public static <T> void swap(T[] array, int i, int j) {
T temp = array[i];
array[i] = array[j];
array[j] = temp;
}
// A generic method to print any array
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.print(element + " ");
}
System.out.println();
}
public static void main(String[] args) {
String[] names = {"Alice", "Bob", "Charlie"};
Integer[] numbers = {1, 2, 3, 4, 5};
printArray(names); // Works with String[]
printArray(numbers); // Works with Integer[]
swap(names, 0, 2); // Swap first and last
printArray(names); // Charlie Bob Alice
}
}

The <T> before the return type declares a type parameter. It’s like saying “this method works with some type T, and here’s how I’ll use it.”

3.3 Generic Classes

You can create your own generic classes:

GenericClasses.java
// A simple generic Box class
public class Box<T> {
private T content;
public void set(T content) {
this.content = content;
}
public T get() {
return content;
}
public static void main(String[] args) {
Box<Integer> integerBox = new Box<>();
integerBox.set(42);
Integer value = integerBox.get(); // No cast needed
Box<String> stringBox = new Box<>();
stringBox.set("Hello");
String message = stringBox.get();
}
}

3.4 Bounded Type Parameters

Sometimes you want to restrict what types can be used. For example, you might want to ensure a type implements Comparable:

BoundedGenerics.java
public class BoundedGenerics {
// Only accepts types that implement Comparable
public static <T extends Comparable<T>> T findMax(T[] array) {
if (array == null || array.length == 0) {
return null;
}
T max = array[0];
for (T element : array) {
if (element.compareTo(max) > 0) {
max = element;
}
}
return max;
}
public static void main(String[] args) {
Integer[] numbers = {3, 7, 1, 9, 4};
String[] words = {"apple", "zebra", "banana"};
System.out.println("Max number: " + findMax(numbers)); // 9
System.out.println("Max word: " + findMax(words)); // zebra
}
}

The <T extends Comparable<T>> means “T must be a type that can be compared to itself.”

4. Understanding Type Erasure

Here’s something that surprised me when I first learned about generics. Consider these two lists:

TypeErasure.java
import java.util.*;
public class TypeErasure {
public static void main(String[] args) {
List<String> stringList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();
// Are these different classes at runtime?
System.out.println(stringList.getClass() == integerList.getClass());
// Output: true
}
}

They’re the same class at runtime! This is called type erasure.

This explains why you can’t do things like this:

WontCompile.java
// These won't compile:
// Can't instantiate with a type parameter
T item = new T();
// Can't create a generic array
List<String>[] array = new List<String>[10];
// Can't use instanceof with generics
if (list instanceof List<String>) { } // Error!

The type information simply doesn’t exist at runtime.

5. Common Mistakes I Made

Mistake 1: Thinking Generics Are Like C++ Templates

Coming from C++, I expected generics to work like templates. But they’re fundamentally different:

  • C++ templates: Generate new code for each type used. List<int> and List<string> are different classes.
  • Java generics: One class at runtime. Type checking happens at compile time only.

Mistake 2: Using Raw Types

I sometimes forget the type parameter:

RawTypes.java
import java.util.*;
public class RawTypes {
public static void main(String[] args) {
// Raw type - don't do this!
List rawList = new ArrayList();
rawList.add("String");
rawList.add(42); // No error, but dangerous!
// Always use generics
List<String> genericList = new ArrayList<>();
genericList.add("String");
// genericList.add(42); // Compile error!
}
}

Raw types exist for backward compatibility with pre-Java-5 code. Always use the generic version.

Mistake 3: Confusing Covariance

Arrays are covariant, but generic collections are not:

Covariance.java
import java.util.*;
public class Covariance {
public static void main(String[] args) {
// Arrays: This is allowed (covariance)
Object[] objects = new String[1];
objects[0] = "Hello"; // OK
// Generics: This is NOT allowed (invariant)
// List<Object> list = new ArrayList<String>(); // Compile error!
// If this were allowed, you could do:
// List<Object> list = new ArrayList<String>();
// list.add(42); // Putting Integer into String list!
// String s = ((ArrayList<String>) list).get(0); // ClassCastException!
}
}

If you need flexibility, use wildcards:

Wildcards.java
import java.util.*;
public class Wildcards {
// Accepts List of any type
public static void printList(List<?> list) {
for (Object element : list) {
System.out.println(element);
}
}
// Accepts List of Number or its subclasses
public static double sum(List<? extends Number> list) {
double total = 0;
for (Number num : list) {
total += num.doubleValue();
}
return total;
}
public static void main(String[] args) {
List<Integer> ints = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.5, 2.5, 3.5);
printList(ints);
printList(doubles);
System.out.println("Sum of ints: " + sum(ints));
System.out.println("Sum of doubles: " + sum(doubles));
}
}

6. Summary

Java Generics solve real problems that every Java developer faces:

Before GenericsWith Generics
Type errors at runtimeType errors at compile time
Manual casting everywhereNo casting needed
Unclear what a List containsList<String> is self-documenting
ClassCastException surprisesSafer, cleaner code

The key points to remember:

  1. Generics provide compile-time type safety. The compiler catches type mismatches before your code runs.
  2. No casting required. When you retrieve elements, the compiler already knows the type.
  3. Type erasure means generics are compile-time only. List<String> and List<Integer> are the same class at runtime.
  4. Always use generics, never raw types. Raw types only exist for backward compatibility.
  5. Use bounded type parameters when you need to restrict what types can be used.

Generics are one of Java’s most important features. Once you understand them, you’ll see them everywhere in the Java standard library and third-party code.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments