00:00

Generics in Java – Type-Safe Classes and Methods

Generics let classes, interfaces and methods work with type parameters, such as List<String> or Map<String, Integer>. The compiler then checks that only the right types are used, and the casts disappear from your code. Generics were added in Java 5 and are the foundation of the Collections Framework and most modern APIs.

This tutorial covers why generics exist, generic classes and methods, bounded type parameters, wildcards and the PECS rule, type erasure, and the restrictions it imposes.

1. Why Generics?

WhyGenerics.java
import java.util.ArrayList;
import java.util.List;

public class WhyGenerics {
    @SuppressWarnings({"rawtypes", "unchecked"})
    public static void main(String[] args) {
        // Without generics (raw type): anything goes in, casts needed, errors at run time
        List raw = new ArrayList();
        raw.add("Java");
        raw.add(42);                                // compiles
        try {
            for (Object o : raw) {
                String s = (String) o;              // ClassCastException on 42
                System.out.println("raw: " + s.toUpperCase());
            }
        } catch (ClassCastException e) {
            System.out.println("raw: ClassCastException at run time");
        }

        // With generics: the compiler enforces the element type, no casts
        List<String> names = new ArrayList<>();
        names.add("Java");
        // names.add(42);                           // compile error
        for (String s : names) System.out.println("generic: " + s.toUpperCase());
    }
}
Output
raw: JAVA
raw: ClassCastException at run time
generic: JAVA
  • Type safety: errors are caught at compile time instead of as ClassCastException at run time.
  • No casts: list.get(0) already has the right type.
  • Reusable code: one implementation works for many types.

2. Generic Classes and Records

Declare type parameters in angle brackets after the class name. By convention they are single capital letters: T (type), E (element), K/V (key/value), R (result):

GenericClass.java
public class GenericClass {
    // A generic class with one type parameter T
    static class Box<T> {
        private T value;
        Box(T value) { this.value = value; }
        T get() { return value; }
        void set(T value) { this.value = value; }
        boolean isEmpty() { return value == null; }
    }

    // Two type parameters
    record Pair<K, V>(K key, V value) {
        <R> Pair<K, R> withValue(R newValue) { return new Pair<>(key, newValue); }
    }

    public static void main(String[] args) {
        Box<String> name = new Box<>("Asha");
        Box<Integer> age = new Box<>(29);
        String n = name.get();                      // no cast
        int a = age.get();                          // auto-unboxing
        System.out.println(n + " is " + a);

        Pair<String, Integer> score = new Pair<>("Ravi", 87);
        Pair<String, String> grade = score.withValue("A");
        System.out.println(score + " -> " + grade);
    }
}
Output
Asha is 29
Pair[key=Ravi, value=87] -> Pair[key=Ravi, value=A]

The diamond operator <> (Java 7) lets the compiler infer type arguments: new Box<>("Asha").

3. Generic Methods and Bounded Types

A method can declare its own type parameters before the return type. Bounds restrict which types are allowed, so you can call methods of the bound:

GenericMethods.java
import java.util.*;

public class GenericMethods {
    // Generic method: <T> before the return type
    static <T> T firstOrDefault(List<T> list, T fallback) {
        return list.isEmpty() ? fallback : list.get(0);
    }

    // Bounded type parameter: T must be Comparable to itself
    static <T extends Comparable<T>> T max(List<T> items) {
        T best = items.get(0);
        for (T item : items) if (item.compareTo(best) > 0) best = item;
        return best;
    }

    // Multiple bounds: a Number that is also Comparable
    static <T extends Number & Comparable<T>> double sumAbove(List<T> nums, T limit) {
        double sum = 0;
        for (T n : nums) if (n.compareTo(limit) > 0) sum += n.doubleValue();
        return sum;
    }

    public static void main(String[] args) {
        System.out.println(firstOrDefault(List.of("kafka", "spring"), "none"));
        System.out.println(firstOrDefault(List.<String>of(), "none"));
        System.out.println(max(List.of(4, 19, 7)));
        System.out.println(max(List.of("pear", "apple", "zucchini")));
        System.out.println(sumAbove(List.of(5, 12, 30, 8), 10));
        System.out.println(Collections.<Integer>emptyList());     // explicit type argument
    }
}
Output
kafka
none
19
zucchini
42.0
[]
  • <T extends Comparable<T>>: T must be comparable, so compareTo can be called.
  • <T extends Number & Comparable<T>>: multiple bounds; a class bound must come first.
  • extends is used for both classes and interfaces in bounds.

4. Wildcards and PECS

Generic types are invariant: a List<Integer> is not a List<Number>, even though Integer is a Number. Wildcards add flexibility:

Wildcards.java
import java.util.*;

public class Wildcards {
    // Upper bounded: read Numbers from any list of Number subtypes (producer)
    static double total(List<? extends Number> nums) {
        double sum = 0;
        for (Number n : nums) sum += n.doubleValue();
        return sum;
    }

    // Lower bounded: write Integers into any list that can hold them (consumer)
    static void addSquares(List<? super Integer> target, int count) {
        for (int i = 1; i <= count; i++) target.add(i * i);
    }

    // Unbounded: works with any element type when only Object methods are needed
    static void printAll(Collection<?> items) {
        System.out.println("size " + items.size() + ": " + items);
    }

    public static void main(String[] args) {
        List<Integer> ints = List.of(1, 2, 3);
        List<Double> doubles = List.of(1.5, 2.5);
        System.out.println("total ints    = " + total(ints));
        System.out.println("total doubles = " + total(doubles));

        List<Number> numbers = new ArrayList<>();
        List<Object> objects = new ArrayList<>();
        addSquares(numbers, 3);
        addSquares(objects, 2);
        printAll(numbers);
        printAll(objects);
        printAll(Set.of("x"));

        // List<Number> n = ints;     // compile error: List<Integer> is NOT a List<Number>
    }
}
Output
total ints    = 6.0
total doubles = 4.0
size 3: [1, 4, 9]
size 2: [1, 4]
size 1: [x]
WildcardAcceptsCan read asCan add
<? extends T>T or any subtypeTNothing (except null)
<? super T>T or any supertypeObjectT and its subtypes
<?>Any typeObjectNothing (except null)

Remember PECS: Producer Extends, Consumer Super. If a parameter produces values you read, use extends; if it consumes values you write, use super. Collections.copy(List<? super T> dest, List<? extends T> src) uses both.

5. Type Erasure

Generics are a compile-time feature. The compiler checks types, inserts casts, and then erases type arguments, replacing them with their bounds (or Object). At run time a List<String> and a List<Integer> are the same class:

TypeErasure.java
import java.util.*;

public class TypeErasure {
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        List<Integer> numbers = new ArrayList<>();
        // Type arguments are erased at run time: both are just ArrayList
        System.out.println("Same runtime class: " + (strings.getClass() == numbers.getClass()));
        System.out.println("Runtime class:      " + strings.getClass().getName());

        // Things erasure forbids (compile errors):
        // new T();                     cannot instantiate a type parameter
        // new List<String>[10];        no generic array creation
        // obj instanceof List<String>  type arguments not available at run time
        // List<int>                    primitives not allowed; use List<Integer>
    }
}
Output
Same runtime class: true
Runtime class:      java.util.ArrayList

Restrictions caused by erasure

  • No primitives as type arguments: use List<Integer>, not List<int>.
  • Cannot create instances (new T()) or arrays (new T[10]) of a type parameter; pass a Class<T> or Supplier<T> instead.
  • Cannot use instanceof with parameterized types, or overload methods that differ only in type arguments.
  • Static fields cannot use the class's type parameter.
  • Generic classes cannot extend Throwable.

6. Raw Types

Using a generic type without type arguments (List list) creates a raw type, which exists only for compatibility with pre-Java 5 code. Raw types switch off generic type checking and produce "unchecked" warnings. Use List<?> when you truly do not care about the element type.

7. Summary

  • Generics add type parameters to classes, interfaces and methods for compile-time type safety without casts.
  • Bounded types (T extends X) allow calling methods of the bound.
  • Generic types are invariant; wildcards follow PECS: producer extends, consumer super.
  • Type arguments are erased at run time, which explains the main restrictions.
  • Avoid raw types in new code.

Related Tutorials