Introduction to Java Generics

You’ve used generics every time you created an ArrayList. The angle brackets in ArrayList<String> specify what type the list holds. Without this, you’d have to cast objects and risk runtime errors. Generics let you write type-safe, reusable code.

The Problem Generics Solve

Before generics (pre-Java 5), collections held Object references:

// Old way - no type safety
ArrayList list = new ArrayList();
list.add("Hello");
list.add(123);  // No compile error - but this is probably a bug

String s = (String) list.get(0);  // Must cast
String s2 = (String) list.get(1);  // ClassCastException at runtime!

Errors appeared only at runtime. With generics:

// With generics - type safe
ArrayList<String> list = new ArrayList<>();
list.add("Hello");
// list.add(123);  // Compile error! Can't add Integer to String list

String s = list.get(0);  // No cast needed

The compiler catches type errors immediately. No casting required.

Using Generic Classes

Specify the type parameter in angle brackets:

List<String> names = new ArrayList<>();
Map<String, Integer> ages = new HashMap<>();
Set<Double> prices = new HashSet<>();

The diamond operator <> on the right lets Java infer the type from the left side.

Type parameters must be reference types. Use wrapper classes for primitives:

List<Integer> numbers = new ArrayList<>();  // Not List<int>
List<Double> values = new ArrayList<>();    // Not List<double>

Creating a Generic Class

Define your own generic class with a type parameter:

public class Box<T> {
    private T content;
    
    public void put(T item) {
        this.content = item;
    }
    
    public T get() {
        return content;
    }
    
    public boolean isEmpty() {
        return content == null;
    }
}

The T is a placeholder for any type. When you create a Box, you specify what T becomes:

Box<String> stringBox = new Box<>();
stringBox.put("Hello");
String s = stringBox.get();  // No cast needed

Box<Integer> intBox = new Box<>();
intBox.put(42);
int n = intBox.get();

Box<List<String>> listBox = new Box<>();
listBox.put(new ArrayList<>());

One class definition works with any type.

Common Type Parameter Names

By convention, single uppercase letters represent type parameters:

  • T – Type (general purpose)
  • E – Element (used in collections)
  • K – Key
  • V – Value
  • N – Number
  • S, U, V – Additional type parameters

These are conventions, not requirements. Box<ContentType> works but Box<T> is standard.

Multiple Type Parameters

Classes can have multiple type parameters:

public class Pair<K, V> {
    private K key;
    private 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 + ")";
    }
}
Pair<String, Integer> age = new Pair<>("Alice", 25);
System.out.println(age.getKey());    // Alice
System.out.println(age.getValue());  // 25

Pair<Integer, String> lookup = new Pair<>(404, "Not Found");
System.out.println(lookup);  // (404, Not Found)

Generic Methods

Methods can have their own type parameters, independent of any class parameters:

public class Utility {
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.print(element + " ");
        }
        System.out.println();
    }
    
    public static <T> T getFirst(List<T> list) {
        if (list.isEmpty()) {
            return null;
        }
        return list.get(0);
    }
}

The <T> before the return type declares the method’s type parameter:

String[] names = {"Alice", "Bob", "Charlie"};
Integer[] numbers = {1, 2, 3, 4, 5};

Utility.printArray(names);    // Alice Bob Charlie
Utility.printArray(numbers);  // 1 2 3 4 5

List<String> list = Arrays.asList("X", "Y", "Z");
String first = Utility.getFirst(list);  // X

Java infers the type from the arguments.

Bounded Type Parameters

Sometimes you need to restrict what types are allowed. Use extends to set an upper bound:

public class NumberBox<T extends Number> {
    private T number;
    
    public NumberBox(T number) {
        this.number = number;
    }
    
    public double getDoubleValue() {
        return number.doubleValue();  // Can call Number methods
    }
}

Only Number and its subclasses (Integer, Double, etc.) are allowed:

NumberBox<Integer> intBox = new NumberBox<>(42);
NumberBox<Double> doubleBox = new NumberBox<>(3.14);
// NumberBox<String> stringBox = new NumberBox<>("Hi");  // Error!

Bounds let you call methods of the bound type inside the generic class.

Multiple Bounds

A type parameter can have multiple bounds:

public class Processor<T extends Comparable<T> & Serializable> {
    // T must implement both Comparable and Serializable
}

If one bound is a class, it must come first. Interfaces follow with &.

Wildcards

Sometimes you need flexibility in what types a method accepts.

Unbounded Wildcard (?)

Accepts any type:

public static void printList(List<?> list) {
    for (Object item : list) {
        System.out.println(item);
    }
}

List<String> strings = Arrays.asList("A", "B", "C");
List<Integer> numbers = Arrays.asList(1, 2, 3);

printList(strings);  // Works
printList(numbers);  // Works

Upper Bounded Wildcard (? extends)

Accepts a type or any subtype:

public static double sumList(List<? extends Number> list) {
    double sum = 0;
    for (Number n : list) {
        sum += n.doubleValue();
    }
    return sum;
}

List<Integer> ints = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.5, 2.5, 3.5);

System.out.println(sumList(ints));     // 6.0
System.out.println(sumList(doubles));  // 7.5

Lower Bounded Wildcard (? super)

Accepts a type or any supertype:

public static void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
    list.add(3);
}

List<Number> numbers = new ArrayList<>();
List<Object> objects = new ArrayList<>();

addNumbers(numbers);  // Works
addNumbers(objects);  // Works

Use ? extends when you read from a structure (producer). Use ? super when you write to it (consumer). This is the PECS principle: Producer Extends, Consumer Super.

Generic Interfaces

Interfaces can be generic too:

public interface Repository<T> {
    void save(T entity);
    T findById(int id);
    List<T> findAll();
    void delete(T entity);
}

public class UserRepository implements Repository<User> {
    private List<User> users = new ArrayList<>();
    
    @Override
    public void save(User user) {
        users.add(user);
    }
    
    @Override
    public User findById(int id) {
        return users.stream()
            .filter(u -> u.getId() == id)
            .findFirst()
            .orElse(null);
    }
    
    @Override
    public List<User> findAll() {
        return new ArrayList<>(users);
    }
    
    @Override
    public void delete(User user) {
        users.remove(user);
    }
}

Practical Example: Generic Stack

public class Stack<E> {
    private ArrayList<E> elements;
    
    public Stack() {
        elements = new ArrayList<>();
    }
    
    public void push(E item) {
        elements.add(item);
    }
    
    public E pop() {
        if (isEmpty()) {
            throw new RuntimeException("Stack is empty");
        }
        return elements.remove(elements.size() - 1);
    }
    
    public E peek() {
        if (isEmpty()) {
            throw new RuntimeException("Stack is empty");
        }
        return elements.get(elements.size() - 1);
    }
    
    public boolean isEmpty() {
        return elements.isEmpty();
    }
    
    public int size() {
        return elements.size();
    }
    
    @Override
    public String toString() {
        return elements.toString();
    }
}
Stack<String> browserHistory = new Stack<>();
browserHistory.push("google.com");
browserHistory.push("github.com");
browserHistory.push("stackoverflow.com");

System.out.println("Current: " + browserHistory.peek());
browserHistory.pop();
System.out.println("Back to: " + browserHistory.peek());

Stack<Integer> undoStack = new Stack<>();
undoStack.push(100);
undoStack.push(200);
System.out.println("Undo value: " + undoStack.pop());

Output:

Current: stackoverflow.com
Back to: github.com
Undo value: 200

Type Erasure

Java implements generics through type erasure. The compiler checks types, then removes generic information from bytecode. At runtime, ArrayList<String> and ArrayList<Integer> are both just ArrayList.

This has implications:

// Cannot create generic arrays
// T[] array = new T[10];  // Error

// Cannot use instanceof with generic types
// if (obj instanceof List<String>)  // Error

// Cannot catch generic exceptions
// catch (GenericException<T> e)  // Error

These limitations exist because the runtime doesn’t have type parameter information.

Common Mistakes

Using raw types. Don’t use List when you mean List<String>. Raw types lose type safety.

Mixing generic and raw types. Leads to unchecked warnings and potential runtime errors.

Creating arrays of generic types. Use ArrayList instead. new T[10] doesn’t work due to type erasure.

Overcomplicating with wildcards. Start simple. Add wildcards only when needed for flexibility.

Wrapping Up

You’ve completed the core Java curriculum. From variables and loops to object-oriented programming, exceptions, file I/O, collections, and generics. These fundamentals prepare you for frameworks like Spring, Android development, or any Java project.

Keep building. The best way to solidify these concepts is to write real programs. Pick a project that interests you and start coding.


Related: Java Collections Framework Overview | 10 Java Interview Questions Every Entry-Level Developer Should Know

Sources

  • Oracle. “Generics.” The Java Tutorials. docs.oracle.com
  • Bloch, Joshua. “Effective Java.” 3rd Edition. Addison-Wesley, 2018.
Scroll to Top