Generics

1. What are Generics?

Generics allow you to write classes, interfaces, and methods that operate on types specified by the caller, rather than hardcoded into the implementation. Introduced in Java 5 (JDK 1.5), generics bring type safety at compile time — the compiler catches type mismatches before your code ever runs, eliminating an entire category of runtime errors.

Before generics, collections stored everything as Object. You could put a String into a List, then accidentally retrieve it as an Integer, and the compiler would not complain. The error would only surface at runtime as a ClassCastException — often in production, often at the worst possible time.

The Problem Generics Solve

Consider this pre-generics code (Java 1.4 and earlier):

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

public class WithoutGenerics {
    public static void main(String[] args) {
        // Raw List -- no type information. Anything goes.
        List names = new ArrayList();
        names.add("Alice");
        names.add("Bob");
        names.add(42);  // No compiler error! An Integer slipped into a "names" list.

        // Later, when we retrieve elements...
        for (int i = 0; i < names.size(); i++) {
            String name = (String) names.get(i); // Manual cast required every time
            System.out.println(name.toUpperCase());
        }
        // Output:
        // ALICE
        // BOB
        // Exception in thread "main" java.lang.ClassCastException:
        //   java.lang.Integer cannot be cast to java.lang.String
    }
}

There are three problems with the raw type approach:

  1. No compile-time type checking -- the Integer 42 was silently added to a list that should only contain strings
  2. Manual casting -- every retrieval requires (String) cast, which is tedious and error-prone
  3. Runtime failure -- the ClassCastException only appears when the code runs, not when it compiles

Now here is the same code with generics:

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

public class WithGenerics {
    public static void main(String[] args) {
        // Parameterized List -- only String elements allowed
        List names = new ArrayList<>();
        names.add("Alice");
        names.add("Bob");
        // names.add(42);  // COMPILE ERROR: incompatible types: int cannot be converted to String

        // No casting needed -- the compiler knows every element is a String
        for (String name : names) {
            System.out.println(name.toUpperCase());
        }
        // Output:
        // ALICE
        // BOB
    }
}

Before vs After Generics

Aspect Without Generics (Raw Types) With Generics
Declaration List names = new ArrayList(); List<String> names = new ArrayList<>();
Type safety None -- any Object can be added Enforced at compile time
Retrieval Manual cast: (String) list.get(0) No cast needed: list.get(0)
Error detection Runtime (ClassCastException) Compile time (compiler error)
Readability Type intent is hidden Type intent is self-documenting

The golden rule: Generics move type errors from runtime to compile time. A bug caught by the compiler costs you 30 seconds. A bug caught in production costs you hours -- or worse.

2. Generic Classes

A generic class is a class that declares one or more type parameters in angle brackets after its name. These type parameters act as placeholders -- they are replaced with actual types when the class is instantiated. This allows you to write a single class that works with any type while maintaining full type safety.

Basic Syntax

The general form of a generic class declaration is:

// T is a type parameter -- a placeholder for a real type
public class Box {
    private T content;

    public Box(T content) {
        this.content = content;
    }

    public T getContent() {
        return content;
    }

    public void setContent(T content) {
        this.content = content;
    }

    @Override
    public String toString() {
        return "Box[" + content + "]";
    }
}

// Usage -- T is replaced with actual types at instantiation
public class BoxDemo {
    public static void main(String[] args) {
        // Box of String
        Box nameBox = new Box<>("Alice");
        String name = nameBox.getContent(); // No cast needed
        System.out.println(name); // Output: Alice

        // Box of Integer
        Box ageBox = new Box<>(30);
        int age = ageBox.getContent(); // Autoboxing/unboxing works seamlessly
        System.out.println(age); // Output: 30

        // Box of a custom type
        Box> listBox = new Box<>(List.of("A", "B", "C"));
        List items = listBox.getContent();
        System.out.println(items); // Output: [A, B, C]
    }
}

Type Parameter Naming Conventions

By convention, type parameters use single uppercase letters. This distinguishes them from class names at a glance. Here are the standard conventions used throughout the Java ecosystem:

Letter Convention Example Usage
T Type (general purpose) Box<T>, Optional<T>
E Element (used in collections) List<E>, Set<E>, Queue<E>
K Key (used in maps) Map<K, V>
V Value (used in maps) Map<K, V>
N Number Calculator<N extends Number>
S, U, V Second, third, fourth types Function<T, R>, BiFunction<T, U, R>
R Return type Function<T, R>, Callable<R>

Multiple Type Parameters

A generic class can declare multiple type parameters, separated by commas. A classic example is a Pair class that holds two values of potentially different types:

public class Pair {
    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 + ")";
    }

    // Static factory method -- type inference handles the type parameters
    public static  Pair of(K key, V value) {
        return new Pair<>(key, value);
    }
}

public class PairDemo {
    public static void main(String[] args) {
        // Explicit type arguments
        Pair entry = new Pair<>("age", 30);
        System.out.println(entry.getKey());   // Output: age
        System.out.println(entry.getValue()); // Output: 30

        // Using the factory method -- types are inferred
        Pair> config = Pair.of("roles", List.of("ADMIN", "USER"));
        System.out.println(config); // Output: (roles, [ADMIN, USER])

        // Pair of Pairs -- generics compose naturally
        Pair> location = Pair.of("Office", Pair.of(37.7749, -122.4194));
        System.out.println(location); // Output: (Office, (37.7749, -122.4194))
    }
}

Generic Class with Bounded Type

You can constrain a type parameter so that it only accepts types that extend a particular class or implement a particular interface. This lets you call methods of the bound type inside your generic class:

// Only accepts types that are Numbers (Integer, Double, Long, etc.)
public class NumberBox {
    private T number;

    public NumberBox(T number) {
        this.number = number;
    }

    public double doubleValue() {
        // Because T extends Number, we can call Number's methods
        return number.doubleValue();
    }

    public boolean isPositive() {
        return number.doubleValue() > 0;
    }

    public T getNumber() {
        return number;
    }
}

public class NumberBoxDemo {
    public static void main(String[] args) {
        NumberBox intBox = new NumberBox<>(42);
        System.out.println(intBox.doubleValue()); // Output: 42.0
        System.out.println(intBox.isPositive());  // Output: true

        NumberBox doubleBox = new NumberBox<>(-3.14);
        System.out.println(doubleBox.isPositive()); // Output: false

        // NumberBox stringBox = new NumberBox<>("hello");
        // COMPILE ERROR: String does not extend Number
    }
}

3. Generic Methods

A generic method is a method that declares its own type parameters, independent of any type parameters on the enclosing class. The type parameter list appears in angle brackets before the return type. Generic methods can appear in generic classes, non-generic classes, or even as static methods.

The compiler infers the type argument from the arguments you pass, so you rarely need to specify it explicitly.

public class GenericMethodDemo {

    // Generic method --  is declared before the return type
    public static  void printArray(T[] array) {
        for (T element : array) {
            System.out.print(element + " ");
        }
        System.out.println();
    }

    // Generic method that returns a value
    public static  T getFirst(List list) {
        if (list == null || list.isEmpty()) {
            return null;
        }
        return list.get(0);
    }

    // Generic method with two type parameters
    public static  Map mapOf(K key, V value) {
        Map map = new HashMap<>();
        map.put(key, value);
        return map;
    }

    // Generic swap method
    public static  void swap(T[] array, int i, int j) {
        T temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }

    // Generic max method -- requires Comparable bound
    public static > T max(T a, T b) {
        return a.compareTo(b) >= 0 ? a : b;
    }

    public static void main(String[] args) {
        // printArray -- type inferred from arguments
        Integer[] intArr = {1, 2, 3, 4, 5};
        String[] strArr = {"Hello", "World"};
        printArray(intArr);  // Output: 1 2 3 4 5
        printArray(strArr);  // Output: Hello World

        // getFirst -- type inferred from list
        List names = List.of("Alice", "Bob", "Charlie");
        String first = getFirst(names); // No cast needed
        System.out.println("First: " + first); // Output: First: Alice

        // swap
        String[] colors = {"Red", "Blue", "Green"};
        swap(colors, 0, 2);
        printArray(colors); // Output: Green Blue Red

        // max -- works with any Comparable type
        System.out.println(max(10, 20));          // Output: 20
        System.out.println(max("apple", "banana")); // Output: banana
        System.out.println(max(3.14, 2.71));      // Output: 3.14
    }
}

Generic Methods in Non-Generic Classes

A key insight is that a method can be generic even if its enclosing class is not. This is common for utility classes:

// This is NOT a generic class -- no type parameter on the class itself
public class ArrayUtils {

    // But these are generic methods
    public static  boolean contains(T[] array, T target) {
        for (T element : array) {
            if (element.equals(target)) {
                return true;
            }
        }
        return false;
    }

    public static  int indexOf(T[] array, T target) {
        for (int i = 0; i < array.length; i++) {
            if (array[i].equals(target)) {
                return i;
            }
        }
        return -1;
    }

    public static  T[] reverse(T[] array) {
        @SuppressWarnings("unchecked")
        T[] reversed = (T[]) java.lang.reflect.Array.newInstance(
            array.getClass().getComponentType(), array.length);
        for (int i = 0; i < array.length; i++) {
            reversed[i] = array[array.length - 1 - i];
        }
        return reversed;
    }

    public static void main(String[] args) {
        String[] fruits = {"Apple", "Banana", "Cherry"};

        System.out.println(contains(fruits, "Banana")); // Output: true
        System.out.println(contains(fruits, "Grape"));  // Output: false
        System.out.println(indexOf(fruits, "Cherry"));  // Output: 2

        String[] reversed = reverse(fruits);
        System.out.println(Arrays.toString(reversed)); // Output: [Cherry, Banana, Apple]
    }
}

4. Bounded Type Parameters

Sometimes you need a type parameter that is not completely open-ended. You want to restrict it to a family of types so that you can call specific methods on it. This is where bounded type parameters come in.

Upper Bounds with extends

The extends keyword in a type parameter bound means "is a subtype of" -- this works for both classes and interfaces. When you write <T extends Number>, T can be Integer, Double, Long, or any other subclass of Number.

Why is this useful? Because inside the method or class, you can call any method defined on the bound type. Without the bound, T is effectively Object, and you can only call toString(), equals(), and hashCode().

public class BoundedTypeDemo {

    // Without bound: T is Object -- we can't call .doubleValue()
    // public static  double sum(List numbers) {
    //     double total = 0;
    //     for (T n : numbers) {
    //         total += n.doubleValue(); // COMPILE ERROR: Object has no doubleValue()
    //     }
    //     return total;
    // }

    // With upper bound: T must be a Number -- now we can call doubleValue()
    public static  double sum(List numbers) {
        double total = 0;
        for (T n : numbers) {
            total += n.doubleValue(); // OK: Number has doubleValue()
        }
        return total;
    }

    // Finding the maximum in a list -- T must be Comparable
    public static > T findMax(List list) {
        if (list == null || list.isEmpty()) {
            throw new IllegalArgumentException("List must not be null or empty");
        }
        T max = list.get(0);
        for (int i = 1; i < list.size(); i++) {
            if (list.get(i).compareTo(max) > 0) {
                max = list.get(i);
            }
        }
        return max;
    }

    public static void main(String[] args) {
        List integers = List.of(10, 20, 30);
        List doubles = List.of(1.5, 2.5, 3.5);

        System.out.println("Sum of integers: " + sum(integers)); // Output: Sum of integers: 60.0
        System.out.println("Sum of doubles: " + sum(doubles));   // Output: Sum of doubles: 7.5

        // sum(List.of("a", "b")); // COMPILE ERROR: String does not extend Number

        List words = List.of("banana", "apple", "cherry");
        System.out.println("Max word: " + findMax(words));      // Output: Max word: cherry
        System.out.println("Max integer: " + findMax(integers)); // Output: Max integer: 30
    }
}

Multiple Bounds

A type parameter can have multiple bounds, separated by &. The syntax is <T extends ClassA & InterfaceB & InterfaceC>. If one of the bounds is a class (not an interface), it must be listed first.

import java.io.Serializable;

public class MultipleBoundsDemo {

    // T must be Comparable AND Serializable
    public static  & Serializable> T clampedMax(T a, T b, T ceiling) {
        T larger = a.compareTo(b) >= 0 ? a : b;
        return larger.compareTo(ceiling) > 0 ? ceiling : larger;
    }

    // A practical example: a method that requires both Number and Comparable
    public static > T clamp(T value, T min, T max) {
        if (value.compareTo(min) < 0) return min;
        if (value.compareTo(max) > 0) return max;
        return value;
    }

    public static void main(String[] args) {
        // Integer implements both Comparable and Serializable
        System.out.println(clampedMax(10, 20, 15)); // Output: 15

        // clamp example
        System.out.println(clamp(5, 1, 10));   // Output: 5 (within range)
        System.out.println(clamp(-3, 1, 10));  // Output: 1 (below min, clamped up)
        System.out.println(clamp(15, 1, 10));  // Output: 10 (above max, clamped down)
        System.out.println(clamp(3.7, 1.0, 5.0)); // Output: 3.7
    }
}

5. Wildcards

Wildcards (?) represent an unknown type. They are used in type arguments (not type parameters) -- that is, you use them when using a generic type, not when declaring one. Wildcards are essential for writing flexible APIs that can accept a range of parameterized types.

There are three kinds of wildcards:

Wildcard Syntax Meaning Use Case
Upper bounded ? extends Type Any subtype of Type Reading from a structure (producer)
Lower bounded ? super Type Any supertype of Type Writing to a structure (consumer)
Unbounded ? Any type at all When the type does not matter

Upper Bounded Wildcards (? extends Type)

Use upper bounded wildcards when you want to read from a generic structure. You know the elements are at least the bound type, so you can safely read them as that type. However, you cannot write to such a structure (except null), because the compiler does not know the exact type.

public class UpperBoundedWildcardDemo {

    // This method accepts List, List, List, etc.
    public static double sumOfList(List list) {
        double total = 0;
        for (Number n : list) { // Safe to read as Number
            total += n.doubleValue();
        }
        return total;
    }

    // Without the wildcard, this would ONLY accept List -- not List!
    // Remember: List is NOT a subtype of List, even though Integer IS a subtype of Number.
    // This is called "invariance" and it's a critical concept to understand.

    public static void main(String[] args) {
        List integers = List.of(1, 2, 3);
        List doubles = List.of(1.5, 2.5, 3.5);
        List numbers = List.of(1, 2.5, 3L);

        System.out.println(sumOfList(integers)); // Output: 6.0
        System.out.println(sumOfList(doubles));  // Output: 7.5
        System.out.println(sumOfList(numbers));  // Output: 6.5

        // Why can't we add to a List?
        List unknown = integers;
        // unknown.add(42);    // COMPILE ERROR!
        // unknown.add(3.14);  // COMPILE ERROR!
        // The compiler doesn't know if the list is List, List, etc.
        // Adding a Double to a List would break type safety.
        Number first = unknown.get(0); // But READING is fine
        System.out.println("First: " + first); // Output: First: 1
    }
}

Lower Bounded Wildcards (? super Type)

Use lower bounded wildcards when you want to write to a generic structure. You know the structure accepts at least the bound type and all its subtypes, so you can safely add elements of that type. However, when you read from it, you can only guarantee the element is an Object.

public class LowerBoundedWildcardDemo {

    // This method accepts List, List, List
    public static void addIntegers(List list) {
        list.add(1);   // Safe: Integer is always accepted
        list.add(2);
        list.add(3);
        // list.add(3.14); // COMPILE ERROR: Double is not Integer
    }

    // Copy elements from a producer to a consumer
    public static  void copy(List source, List dest) {
        for (T item : source) {
            dest.add(item);
        }
    }

    public static void main(String[] args) {
        // List -- can hold Integer
        List intList = new ArrayList<>();
        addIntegers(intList);
        System.out.println(intList); // Output: [1, 2, 3]

        // List -- can hold Integer (since Integer extends Number)
        List numList = new ArrayList<>();
        addIntegers(numList);
        System.out.println(numList); // Output: [1, 2, 3]

        // List -- can hold Integer (since Integer extends Object)
        List objList = new ArrayList<>();
        addIntegers(objList);
        System.out.println(objList); // Output: [1, 2, 3]

        // copy example
        List source = List.of(10, 20, 30);
        List destination = new ArrayList<>();
        copy(source, destination);
        System.out.println(destination); // Output: [10, 20, 30]
    }
}

The PECS Principle (Producer Extends, Consumer Super)

Joshua Bloch coined this mnemonic in Effective Java, and it is the single most important rule for using wildcards correctly:

  • Producer Extends: If a parameterized type produces (provides) elements of type T, use ? extends T. You are reading from it.
  • Consumer Super: If a parameterized type consumes (accepts) elements of type T, use ? super T. You are writing to it.
  • If a parameter both produces and consumes, do not use wildcards -- use an exact type.
import java.util.*;
import java.util.function.Predicate;

public class PECSDemo {

    // 'src' is a PRODUCER (we read from it) -- use extends
    // 'dest' is a CONSUMER (we write to it) -- use super
    public static  void transferFiltered(
            List src,       // Producer: extends
            List dest,        // Consumer: super
            Predicate filter) // Consumer: super (it "consumes" T to test it)
    {
        for (T item : src) {
            if (filter.test(item)) {
                dest.add(item);
            }
        }
    }

    public static void main(String[] args) {
        List source = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
        List evens = new ArrayList<>();

        // Transfer even numbers from List to List
        transferFiltered(source, evens, n -> n % 2 == 0);
        System.out.println("Evens: " + evens); // Output: Evens: [2, 4, 6, 8, 10]
    }
}

Unbounded Wildcards (?)

Use an unbounded wildcard when the actual type does not matter. This is appropriate when you are working with methods from Object (like toString() or size()) or when the method's logic is type-independent.

public class UnboundedWildcardDemo {

    // We don't care what type the list contains -- we just want its size
    public static int countElements(List list) {
        return list.size();
    }

    // Print any list -- we only call toString() on elements
    public static void printList(List list) {
        for (Object element : list) {
            System.out.print(element + " ");
        }
        System.out.println();
    }

    // Check if two lists have the same size
    public static boolean sameSize(List a, List b) {
        return a.size() == b.size();
    }

    public static void main(String[] args) {
        List names = List.of("Alice", "Bob");
        List ages = List.of(30, 25);
        List scores = List.of(95.5, 87.3, 91.0);

        printList(names);  // Output: Alice Bob
        printList(ages);   // Output: 30 25
        printList(scores); // Output: 95.5 87.3 91.0

        System.out.println(countElements(scores)); // Output: 3
        System.out.println(sameSize(names, ages)); // Output: true
    }
}

Wildcard Summary

Scenario Wildcard to Use Can Read As Can Write
Read elements as T ? extends T T Only null
Write elements of type T ? super T Object T and subtypes
Read and write No wildcard (use T) T T
Type does not matter ? Object Only null

6. Type Erasure

Here is the part that trips up many Java developers: generics exist only at compile time. At runtime, the JVM has no concept of generic types. The compiler removes (erases) all type parameter information and replaces it with their bounds (or Object if unbounded). This process is called type erasure.

Type erasure was a deliberate design choice to maintain backward compatibility with pre-generics Java code. It means that List<String> and List<Integer> are the same class at runtime -- they are both just List.

What Happens During Erasure

Generic Code (Compile Time) After Erasure (Runtime)
List<String> List
Box<Integer> Box
<T> T getFirst(List<T>) Object getFirst(List)
<T extends Number> T sum(T a, T b) Number sum(Number a, Number b)
Pair<String, Integer> Pair (fields become Object)

Consequences of Type Erasure

Type erasure imposes several limitations that every Java developer must understand. These are not bugs -- they are inherent trade-offs of the erasure-based generics design:

public class TypeErasureDemo {

    // 1. You CANNOT create instances of type parameters
    public static  T createInstance() {
        // return new T(); // COMPILE ERROR: Type parameter T cannot be instantiated directly
        // Workaround: pass a Supplier or Class
        return null;
    }

    // Workaround using Class
    public static  T createInstance(Class clazz) throws Exception {
        return clazz.getDeclaredConstructor().newInstance();
    }

    // 2. You CANNOT use instanceof with generic types
    public static  void checkType(List list) {
        // if (list instanceof List) {} // COMPILE ERROR
        // At runtime, list is just a List -- the  is gone
        if (list instanceof List) { } // This is OK -- unbounded wildcard is fine
    }

    // 3. You CANNOT create generic arrays
    public static  void cannotCreateGenericArray() {
        // T[] array = new T[10]; // COMPILE ERROR
        // List[] arrayOfLists = new List[10]; // COMPILE ERROR

        // Workaround: use Array.newInstance or collections
        @SuppressWarnings("unchecked")
        T[] workaround = (T[]) new Object[10]; // Works but produces unchecked warning
    }

    // 4. You CANNOT overload methods that differ only by type parameter
    // public static void process(List list) { }
    // public static void process(List list) { } // COMPILE ERROR
    // After erasure, both signatures become process(List list) -- they clash

    // 5. You CANNOT catch or throw generic types
    // class MyException extends Exception { } // COMPILE ERROR
    // The JVM needs to know the exact exception type at runtime

    public static void main(String[] args) throws Exception {
        // Demonstrating that generic types are the same at runtime
        List strings = new ArrayList<>();
        List integers = new ArrayList<>();

        System.out.println(strings.getClass() == integers.getClass()); // Output: true
        System.out.println(strings.getClass().getName()); // Output: java.util.ArrayList
        // Both are just ArrayList at runtime -- the  and  are gone

        // Creating instances via Class
        String s = createInstance(String.class);
        System.out.println("Created: " + s); // Output: Created:
    }
}

Bridge Methods

When a generic class is extended with a concrete type, the compiler sometimes generates bridge methods to maintain polymorphism after type erasure. You will rarely interact with bridge methods directly, but understanding them explains some unexpected behaviors:

// Generic interface
public interface Transformer {
    T transform(T input);
}

// Concrete implementation
public class UpperCaseTransformer implements Transformer {
    @Override
    public String transform(String input) {
        return input.toUpperCase();
    }
}

// After type erasure, the interface becomes:
//   Object transform(Object input)
//
// But UpperCaseTransformer has:
//   String transform(String input)
//
// These are different signatures! The compiler generates a "bridge method":
//   public Object transform(Object input) {
//       return transform((String) input);  // delegates to the real method
//   }
//
// This bridge method ensures that calling transformer.transform(obj)
// via the interface still dispatches to the correct implementation.

public class BridgeMethodDemo {
    public static void main(String[] args) {
        Transformer transformer = new UpperCaseTransformer();
        System.out.println(transformer.transform("hello")); // Output: HELLO

        // The bridge method is visible via reflection
        for (java.lang.reflect.Method m : UpperCaseTransformer.class.getDeclaredMethods()) {
            System.out.println(m.getName() + " - bridge: " + m.isBridge()
                + " - params: " + java.util.Arrays.toString(m.getParameterTypes()));
        }
        // Output:
        // transform - bridge: false - params: [class java.lang.String]
        // transform - bridge: true - params: [class java.lang.Object]
    }
}

7. Generic Interfaces

Just as classes can be generic, interfaces can declare type parameters. In fact, some of the most important interfaces in the Java standard library are generic: Comparable<T>, Iterable<T>, Comparator<T>, Function<T, R>, and more.

When a class implements a generic interface, it has three choices:

Approach Example When to Use
Concrete type class Name implements Comparable<Name> The type is fixed and known
Keep generic class Box<T> implements Container<T> The implementing class is also generic
Raw type class Legacy implements Comparable Never do this -- legacy only
// Defining a generic interface
public interface Repository {
    T findById(ID id);
    List findAll();
    T save(T entity);
    void deleteById(ID id);
    boolean existsById(ID id);
}

// Approach 1: Implement with concrete types
public class UserRepository implements Repository {
    private final Map store = new HashMap<>();

    @Override
    public User findById(Long id) {
        return store.get(id);
    }

    @Override
    public List findAll() {
        return new ArrayList<>(store.values());
    }

    @Override
    public User save(User user) {
        store.put(user.getId(), user);
        return user;
    }

    @Override
    public void deleteById(Long id) {
        store.remove(id);
    }

    @Override
    public boolean existsById(Long id) {
        return store.containsKey(id);
    }
}

// Approach 2: Keep the type parameters -- create a reusable base class
public class InMemoryRepository implements Repository {
    private final Map store = new HashMap<>();
    private final Function idExtractor;

    public InMemoryRepository(Function idExtractor) {
        this.idExtractor = idExtractor;
    }

    @Override
    public T findById(ID id) {
        return store.get(id);
    }

    @Override
    public List findAll() {
        return new ArrayList<>(store.values());
    }

    @Override
    public T save(T entity) {
        store.put(idExtractor.apply(entity), entity);
        return entity;
    }

    @Override
    public void deleteById(ID id) {
        store.remove(id);
    }

    @Override
    public boolean existsById(ID id) {
        return store.containsKey(id);
    }
}

Implementing Comparable<T>

Comparable<T> is arguably the most frequently implemented generic interface. It defines a natural ordering for a class. Here is a proper implementation:

public class Employee implements Comparable {
    private final String name;
    private final double salary;

    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }

    public String getName() { return name; }
    public double getSalary() { return salary; }

    // Natural ordering: by salary (ascending)
    @Override
    public int compareTo(Employee other) {
        return Double.compare(this.salary, other.salary);
    }

    @Override
    public String toString() {
        return name + "($" + salary + ")";
    }
}

public class ComparableDemo {
    public static void main(String[] args) {
        List team = new ArrayList<>(List.of(
            new Employee("Alice", 95000),
            new Employee("Bob", 72000),
            new Employee("Charlie", 110000),
            new Employee("Diana", 88000)
        ));

        // Sort uses Comparable.compareTo()
        Collections.sort(team);
        System.out.println("By salary: " + team);
        // Output: By salary: [Bob($72000.0), Diana($88000.0), Alice($95000.0), Charlie($110000.0)]

        // Collections.min() and max() also use Comparable
        System.out.println("Lowest paid: " + Collections.min(team));  // Output: Lowest paid: Bob($72000.0)
        System.out.println("Highest paid: " + Collections.max(team)); // Output: Highest paid: Charlie($110000.0)
    }
}

8. Generic Collections

Generics and the Collections Framework are inseparable. When generics were introduced in Java 5, the entire Collections API was retrofitted to use them. This is why you write List<String> instead of the raw List. Understanding how generics power collections is essential for everyday Java development.

The Diamond Operator (Java 7+)

Before Java 7, you had to repeat the type arguments on both sides of the assignment:

import java.util.*;

public class GenericCollectionsDemo {

    public static void main(String[] args) {

        // Java 5/6 -- type arguments repeated on both sides (verbose)
        Map> scores1 = new HashMap>();

        // Java 7+ -- diamond operator infers the type arguments
        Map> scores2 = new HashMap<>(); // Much cleaner

        // Java 9+ -- factory methods with implicit typing
        List names = List.of("Alice", "Bob", "Charlie");
        Set primes = Set.of(2, 3, 5, 7, 11);
        Map ages = Map.of("Alice", 30, "Bob", 25);

        // -------------------------------------------------------
        // List -- ordered, allows duplicates
        // -------------------------------------------------------
        List languages = new ArrayList<>();
        languages.add("Java");
        languages.add("Python");
        languages.add("Java"); // Duplicates OK
        String first = languages.get(0); // No cast needed -- type safe
        System.out.println("Languages: " + languages); // Output: Languages: [Java, Python, Java]

        // -------------------------------------------------------
        // Set -- no duplicates
        // -------------------------------------------------------
        Set uniqueLanguages = new LinkedHashSet<>(languages);
        System.out.println("Unique: " + uniqueLanguages); // Output: Unique: [Java, Python]

        // -------------------------------------------------------
        // Map -- key-value pairs
        // -------------------------------------------------------
        Map> courseStudents = new HashMap<>();
        courseStudents.put("CS101", List.of("Alice", "Bob"));
        courseStudents.put("CS201", List.of("Charlie", "Diana"));

        // Type safety carries through nested generics
        List cs101Students = courseStudents.get("CS101"); // returns List, not Object
        System.out.println("CS101: " + cs101Students); // Output: CS101: [Alice, Bob]

        // -------------------------------------------------------
        // Queue -- FIFO ordering
        // -------------------------------------------------------
        Queue taskQueue = new LinkedList<>();
        taskQueue.offer("Build feature");
        taskQueue.offer("Write tests");
        taskQueue.offer("Deploy");
        System.out.println("Next task: " + taskQueue.poll()); // Output: Next task: Build feature

        // -------------------------------------------------------
        // Deque -- double-ended queue (also used as a stack)
        // -------------------------------------------------------
        Deque stack = new ArrayDeque<>();
        stack.push("First");
        stack.push("Second");
        stack.push("Third");
        System.out.println("Stack pop: " + stack.pop()); // Output: Stack pop: Third

        // -------------------------------------------------------
        // PriorityQueue -- elements ordered by natural ordering or Comparator
        // -------------------------------------------------------
        PriorityQueue minHeap = new PriorityQueue<>();
        minHeap.addAll(List.of(30, 10, 50, 20, 40));
        System.out.print("Priority order: ");
        while (!minHeap.isEmpty()) {
            System.out.print(minHeap.poll() + " ");
        }
        // Output: Priority order: 10 20 30 40 50
        System.out.println();
    }
}

Type Safety Benefits in Practice

The real power of generic collections becomes apparent when you chain operations. Every step preserves type information, and the compiler verifies correctness throughout:

import java.util.*;
import java.util.stream.*;

public class TypeSafetyBenefitDemo {

    record Student(String name, int grade, String major) {}

    public static void main(String[] args) {
        List students = List.of(
            new Student("Alice", 92, "CS"),
            new Student("Bob", 85, "Math"),
            new Student("Charlie", 97, "CS"),
            new Student("Diana", 88, "CS"),
            new Student("Eve", 91, "Math")
        );

        // Every step is type-safe -- the compiler tracks types through the entire pipeline
        Map> byMajor = students.stream()
            .collect(Collectors.groupingBy(Student::major)); // Map>

        Map avgGradeByMajor = students.stream()
            .collect(Collectors.groupingBy(
                Student::major,                                   // K = String
                Collectors.averagingInt(Student::grade)            // V = Double
            ));

        System.out.println("CS students: " + byMajor.get("CS"));
        // Output: CS students: [Student[name=Alice, grade=92, major=CS], ...]

        System.out.println("Average grades: " + avgGradeByMajor);
        // Output: Average grades: {CS=92.33333333333333, Math=88.0}

        // Optional -- not Optional
        Optional topStudent = students.stream()
            .max(Comparator.comparingInt(Student::grade));

        topStudent.ifPresent(s ->
            System.out.println("Top student: " + s.name() + " (" + s.grade() + ")")
        );
        // Output: Top student: Charlie (97)
    }
}

9. Recursive Type Bounds

A recursive type bound is a type parameter that is bounded by an expression involving the type parameter itself. The most common form is <T extends Comparable<T>>, which reads: "T is a type that can compare itself to other instances of T."

This pattern appears throughout the Java standard library and is essential for writing type-safe APIs that deal with ordering, self-referential structures, or fluent builders.

The Classic: <T extends Comparable<T>>

public class RecursiveTypeBoundDemo {

    // Without recursive bound -- less type safe
    // public static  T findMax(List list) {
    //     // Can't call compareTo() because T is just Object
    // }

    // With recursive bound -- T must be comparable to itself
    public static > T findMax(List list) {
        if (list.isEmpty()) throw new IllegalArgumentException("Empty list");
        T max = list.get(0);
        for (T item : list) {
            if (item.compareTo(max) > 0) {
                max = item;
            }
        }
        return max;
    }

    // Sorting with recursive bounds
    public static > List sorted(List list) {
        List copy = new ArrayList<>(list);
        Collections.sort(copy); // Works because T is Comparable
        return copy;
    }

    // Range check with recursive bounds
    public static > boolean isBetween(T value, T low, T high) {
        return value.compareTo(low) >= 0 && value.compareTo(high) <= 0;
    }

    public static void main(String[] args) {
        System.out.println(findMax(List.of(3, 1, 4, 1, 5, 9))); // Output: 9
        System.out.println(findMax(List.of("banana", "apple", "cherry"))); // Output: cherry

        System.out.println(sorted(List.of(5, 2, 8, 1, 9))); // Output: [1, 2, 5, 8, 9]

        System.out.println(isBetween(5, 1, 10));  // Output: true
        System.out.println(isBetween(15, 1, 10)); // Output: false
        System.out.println(isBetween("dog", "cat", "fox")); // Output: true
    }
}

Fluent Builder with Recursive Generics

Recursive type bounds enable the curiously recurring template pattern (CRTP), which is particularly useful for builder hierarchies. Without it, a subclass builder's methods would return the parent builder type, breaking method chaining.

// Base class with a self-referential builder
public abstract class Pizza {
    private final String size;
    private final boolean cheese;
    private final boolean pepperoni;
    private final boolean mushrooms;

    // T extends Builder -- the recursive bound
    protected abstract static class Builder> {
        private String size;
        private boolean cheese;
        private boolean pepperoni;
        private boolean mushrooms;

        // Each method returns T (the concrete builder type), not Builder
        @SuppressWarnings("unchecked")
        protected T self() {
            return (T) this;
        }

        public T size(String size) {
            this.size = size;
            return self();
        }

        public T cheese(boolean cheese) {
            this.cheese = cheese;
            return self();
        }

        public T pepperoni(boolean pepperoni) {
            this.pepperoni = pepperoni;
            return self();
        }

        public T mushrooms(boolean mushrooms) {
            this.mushrooms = mushrooms;
            return self();
        }

        public abstract Pizza build();
    }

    protected Pizza(Builder builder) {
        this.size = builder.size;
        this.cheese = builder.cheese;
        this.pepperoni = builder.pepperoni;
        this.mushrooms = builder.mushrooms;
    }

    @Override
    public String toString() {
        return size + " pizza [cheese=" + cheese + ", pepperoni=" + pepperoni
            + ", mushrooms=" + mushrooms + "]";
    }
}

// Subclass with its own builder that adds extra options
public class CalzonePizza extends Pizza {
    private final boolean sauceInside;

    public static class Builder extends Pizza.Builder {
        private boolean sauceInside;

        public Builder sauceInside(boolean sauceInside) {
            this.sauceInside = sauceInside;
            return self();
        }

        @Override
        public CalzonePizza build() {
            return new CalzonePizza(this);
        }

        @Override
        protected Builder self() {
            return this;
        }
    }

    private CalzonePizza(Builder builder) {
        super(builder);
        this.sauceInside = builder.sauceInside;
    }
}

// Usage -- method chaining works correctly across the class hierarchy
public class BuilderDemo {
    public static void main(String[] args) {
        CalzonePizza pizza = new CalzonePizza.Builder()
            .size("Large")           // returns CalzonePizza.Builder, not Pizza.Builder
            .cheese(true)            // still CalzonePizza.Builder
            .pepperoni(true)         // still CalzonePizza.Builder
            .sauceInside(true)       // CalzonePizza.Builder-specific method
            .build();

        System.out.println(pizza);
        // Output: Large pizza [cheese=true, pepperoni=true, mushrooms=false]
    }
}

Understanding Enum<E extends Enum<E>>

The Java Enum class uses the most famous recursive type bound in the language: Enum<E extends Enum<E>>. This ensures that enum methods like compareTo() and valueOf() work with the correct enum type, not just Enum in general.

When you write enum Color { RED, GREEN, BLUE }, the compiler generates class Color extends Enum<Color>. This means:

  • Color.RED.compareTo(Color.BLUE) works because Comparable<Color> is inherited
  • You cannot accidentally compare Color.RED to Size.LARGE -- the type system prevents it
  • Enum.valueOf(Color.class, "RED") returns Color, not Enum

10. Generic Utility Methods

One of the greatest strengths of generics is the ability to write reusable utility methods that work across all types while maintaining compile-time type safety. The Java standard library is full of these (see Collections, Arrays, Objects), and writing your own is a hallmark of senior-level Java development.

import java.util.*;
import java.util.function.*;
import java.util.stream.*;

public class GenericUtilities {

    // -------------------------------------------------------
    // 1. Generic filter -- return elements matching a predicate
    // -------------------------------------------------------
    public static  List filter(List list, Predicate predicate) {
        List result = new ArrayList<>();
        for (T item : list) {
            if (predicate.test(item)) {
                result.add(item);
            }
        }
        return result;
    }

    // -------------------------------------------------------
    // 2. Generic transform (map) -- convert each element
    // -------------------------------------------------------
    public static  List transform(List list, Function mapper) {
        List result = new ArrayList<>(list.size());
        for (T item : list) {
            result.add(mapper.apply(item));
        }
        return result;
    }

    // -------------------------------------------------------
    // 3. Generic reduce -- combine all elements into one value
    // -------------------------------------------------------
    public static  T reduce(List list, T identity, BinaryOperator combiner) {
        T result = identity;
        for (T item : list) {
            result = combiner.apply(result, item);
        }
        return result;
    }

    // -------------------------------------------------------
    // 4. Generic groupBy -- group elements by a classifier
    // -------------------------------------------------------
    public static  Map> groupBy(List list, Function classifier) {
        Map> groups = new LinkedHashMap<>();
        for (T item : list) {
            K key = classifier.apply(item);
            groups.computeIfAbsent(key, k -> new ArrayList<>()).add(item);
        }
        return groups;
    }

    // -------------------------------------------------------
    // 5. Generic partition -- split into two groups by predicate
    // -------------------------------------------------------
    public static  Map> partition(List list, Predicate predicate) {
        Map> result = new LinkedHashMap<>();
        result.put(true, new ArrayList<>());
        result.put(false, new ArrayList<>());
        for (T item : list) {
            result.get(predicate.test(item)).add(item);
        }
        return result;
    }

    // -------------------------------------------------------
    // 6. Generic zip -- combine two lists into a list of pairs
    // -------------------------------------------------------
    public static  List> zip(List first, List second) {
        int size = Math.min(first.size(), second.size());
        List> result = new ArrayList<>(size);
        for (int i = 0; i < size; i++) {
            result.add(Map.entry(first.get(i), second.get(i)));
        }
        return result;
    }

    // -------------------------------------------------------
    // 7. Generic frequency count
    // -------------------------------------------------------
    public static  Map frequencyCount(List list) {
        Map counts = new LinkedHashMap<>();
        for (T item : list) {
            counts.merge(item, 1L, Long::sum);
        }
        return counts;
    }

    public static void main(String[] args) {
        List numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // filter
        List evens = filter(numbers, n -> n % 2 == 0);
        System.out.println("Evens: " + evens); // Output: Evens: [2, 4, 6, 8, 10]

        // transform
        List labels = transform(numbers, n -> "Item-" + n);
        System.out.println("Labels: " + labels);
        // Output: Labels: [Item-1, Item-2, Item-3, ..., Item-10]

        // reduce
        int sum = reduce(numbers, 0, Integer::sum);
        System.out.println("Sum: " + sum); // Output: Sum: 55

        // groupBy
        Map> oddEven = groupBy(numbers, n -> n % 2 == 0 ? "even" : "odd");
        System.out.println("Grouped: " + oddEven);
        // Output: Grouped: {odd=[1, 3, 5, 7, 9], even=[2, 4, 6, 8, 10]}

        // partition
        Map> partitioned = partition(numbers, n -> n > 5);
        System.out.println("Greater than 5: " + partitioned.get(true));  // Output: [6, 7, 8, 9, 10]
        System.out.println("Not greater than 5: " + partitioned.get(false)); // Output: [1, 2, 3, 4, 5]

        // zip
        List names = List.of("Alice", "Bob", "Charlie");
        List ages = List.of(30, 25, 35);
        System.out.println("Zipped: " + zip(names, ages));
        // Output: Zipped: [Alice=30, Bob=25, Charlie=35]

        // frequency count
        List words = List.of("java", "python", "java", "go", "java", "python");
        System.out.println("Frequency: " + frequencyCount(words));
        // Output: Frequency: {java=3, python=2, go=1}
    }
}

Collections Utility Methods Using Generics

The java.util.Collections class is a masterclass in generic utility methods. Here are some of the most useful ones and how they leverage generics:

import java.util.*;

public class CollectionsUtilityDemo {
    public static void main(String[] args) {
        // -------------------------------------------------------
        // Collections.sort() -- >
        // -------------------------------------------------------
        List names = new ArrayList<>(List.of("Charlie", "Alice", "Bob"));
        Collections.sort(names);
        System.out.println("Sorted: " + names); // Output: Sorted: [Alice, Bob, Charlie]

        // -------------------------------------------------------
        // Collections.unmodifiableList() --  List unmodifiableList(List)
        // -------------------------------------------------------
        List readOnly = Collections.unmodifiableList(names);
        // readOnly.add("Diana"); // Throws UnsupportedOperationException
        System.out.println("Unmodifiable: " + readOnly); // Output: Unmodifiable: [Alice, Bob, Charlie]

        // -------------------------------------------------------
        // Collections.singletonList() --  List singletonList(T o)
        // -------------------------------------------------------
        List single = Collections.singletonList("OnlyOne");
        System.out.println("Singleton: " + single); // Output: Singleton: [OnlyOne]

        // -------------------------------------------------------
        // Collections.emptyList/Map/Set --  List emptyList()
        // -------------------------------------------------------
        List empty = Collections.emptyList();
        System.out.println("Empty: " + empty); // Output: Empty: []

        // -------------------------------------------------------
        // Collections.synchronizedList() --  List synchronizedList(List)
        // -------------------------------------------------------
        List threadSafe = Collections.synchronizedList(new ArrayList<>(names));
        System.out.println("Thread-safe: " + threadSafe);

        // -------------------------------------------------------
        // Collections.frequency() -- int frequency(Collection, Object)
        // -------------------------------------------------------
        List items = List.of("a", "b", "a", "c", "a");
        System.out.println("Frequency of 'a': " + Collections.frequency(items, "a"));
        // Output: Frequency of 'a': 3

        // -------------------------------------------------------
        // Collections.min/max with Comparator
        // -------------------------------------------------------
        String longest = Collections.max(names, Comparator.comparingInt(String::length));
        System.out.println("Longest name: " + longest); // Output: Longest name: Charlie
    }
}

11. Common Mistakes

Even experienced developers fall into these traps. Understanding these mistakes will save you hours of debugging and help you write cleaner generic code.

Mistake 1: Using Raw Types

A raw type is a generic type used without any type arguments. Raw types exist only for backward compatibility with pre-Java 5 code. There is never a valid reason to use them in new code.

public class RawTypeMistake {
    public static void main(String[] args) {
        // BAD -- raw type. The compiler cannot help you.
        List names = new ArrayList();
        names.add("Alice");
        names.add(42);          // No error -- but this is a bug
        String first = (String) names.get(0); // Manual cast -- tedious
        // String second = (String) names.get(1); // ClassCastException at runtime!

        // GOOD -- parameterized type. The compiler has your back.
        List safeNames = new ArrayList<>();
        safeNames.add("Alice");
        // safeNames.add(42); // COMPILE ERROR -- bug caught immediately
        String safeFirst = safeNames.get(0); // No cast needed
    }
}

Mistake 2: Trying to Instantiate Type Parameters

public class InstantiationMistake {

    // BAD -- cannot instantiate T directly
    // public static  T createBad() {
    //     return new T(); // COMPILE ERROR
    // }

    // GOOD -- use a Supplier
    public static  T create(java.util.function.Supplier supplier) {
        return supplier.get();
    }

    // GOOD -- use Class with reflection
    public static  T create(Class clazz) throws Exception {
        return clazz.getDeclaredConstructor().newInstance();
    }

    public static void main(String[] args) throws Exception {
        // Using Supplier (preferred -- no reflection, no exceptions)
        String s = create(String::new);
        ArrayList list = create(ArrayList::new);

        // Using Class
        String s2 = create(String.class);
        System.out.println("Created: '" + s2 + "'"); // Output: Created: ''
    }
}

Mistake 3: Generic Array Creation

public class GenericArrayMistake {
    public static void main(String[] args) {
        // BAD -- cannot create generic arrays directly
        // List[] arrayOfLists = new List[10]; // COMPILE ERROR

        // Why? Because arrays are "reified" -- they know their component type at runtime.
        // But generics are erased -- List becomes just List at runtime.
        // If Java allowed this, you could break type safety:

        // Hypothetically, if it were allowed:
        // Object[] objArray = arrayOfLists; // Arrays are covariant, so this would compile
        // objArray[0] = List.of(42);        // This is a List, stored in a List[] slot
        // String s = arrayOfLists[0].get(0); // ClassCastException! Integer is not String

        // GOOD -- use a List of Lists instead
        List> listOfLists = new ArrayList<>();
        listOfLists.add(List.of("Alice", "Bob"));
        listOfLists.add(List.of("Charlie"));

        // GOOD -- if you truly need an array, use a workaround with unchecked cast
        @SuppressWarnings("unchecked")
        List[] array = (List[]) new List[10];
        array[0] = List.of("Hello");
        System.out.println(array[0]); // Output: [Hello]
    }
}

Mistake 4: Confusing extends in Generics vs Class Hierarchy

public class ExtendsConfusion {
    public static void main(String[] args) {
        // In class hierarchy: Integer IS-A Number (subtype relationship)
        Number n = Integer.valueOf(42); // This works -- Integer extends Number

        // But in generics: List IS NOT a List
        // List numbers = new ArrayList(); // COMPILE ERROR!

        // Why? Because if this were allowed:
        // numbers.add(3.14); // This would be legal (Double is a Number)
        // But the actual list is ArrayList -- a Double would corrupt it!

        // This is called INVARIANCE: Generic types are invariant.
        // List and List are NOT related, even though Integer extends Number.

        // Solution 1: Use wildcards
        List readOnly = new ArrayList(); // OK for reading

        // Solution 2: Use the exact type
        List numbers = new ArrayList<>(); // Both Integer and Double can go in
        numbers.add(42);
        numbers.add(3.14);
    }
}

Mistake 5: Overloading with Type Erasure Conflicts

public class ErasureConflict {

    // These two methods look different at the source level...
    // public void process(List strings) { }
    // public void process(List integers) { }

    // ...but after erasure, both become:
    // public void process(List strings) { }
    // public void process(List integers) { }
    // COMPILE ERROR: both methods have same erasure

    // Solution: use different method names
    public void processStrings(List strings) {
        strings.forEach(s -> System.out.println("String: " + s));
    }

    public void processIntegers(List integers) {
        integers.forEach(i -> System.out.println("Integer: " + i));
    }

    // Or use a single generic method
    public  void process(List items, String label) {
        items.forEach(item -> System.out.println(label + ": " + item));
    }
}

Mistake 6: Using Generics with Primitives

public class PrimitiveMistake {
    public static void main(String[] args) {
        // BAD -- generics do not support primitive types
        // List numbers = new ArrayList<>(); // COMPILE ERROR

        // GOOD -- use wrapper classes (autoboxing handles conversion)
        List numbers = new ArrayList<>();
        numbers.add(42);  // autoboxing: int -> Integer
        int value = numbers.get(0); // auto-unboxing: Integer -> int

        // Be aware of boxing overhead in performance-critical code
        // For large datasets, consider specialized collections:
        //   - IntStream, LongStream, DoubleStream (java.util.stream)
        //   - int[], double[] (plain arrays)
        //   - Third-party: Eclipse Collections IntList, Trove TIntArrayList

        // Null danger with auto-unboxing
        List data = new ArrayList<>();
        data.add(null); // Allowed for Integer (it's an object)
        // int bad = data.get(0); // NullPointerException! null cannot unbox to int
    }
}

12. Best Practices

These best practices come from years of experience in the Java ecosystem and are distilled from Effective Java by Joshua Bloch, Java language specifications, and real-world codebase reviews. Following them will make your generic code safer, cleaner, and more maintainable.

Practice 1: Always Use Parameterized Types

Never use raw types. If you do not know the type, use <?> (unbounded wildcard) rather than leaving the type argument off entirely.

// BAD
List names = new ArrayList();
Map config = new HashMap();

// GOOD
List names = new ArrayList<>();
Map config = new HashMap<>();

// If you truly don't know the type, use unbounded wildcard
List unknownList = getListFromSomewhere();
// You can read from it (as Object), check size, iterate, etc.

Practice 2: Use Bounded Wildcards for API Flexibility

Public API methods should use wildcards in their parameters to be as flexible as possible for callers. This follows the PECS principle.

public class WildcardAPIBestPractice {

    // LESS FLEXIBLE -- only accepts List, not List or List
    public static double sumRigid(List numbers) {
        return numbers.stream().mapToDouble(Number::doubleValue).sum();
    }

    // MORE FLEXIBLE -- accepts List, List, List, etc.
    public static double sumFlexible(List numbers) {
        return numbers.stream().mapToDouble(Number::doubleValue).sum();
    }

    // LESS FLEXIBLE -- only accepts Comparator
    public static String maxRigid(List list, Comparator comparator) {
        return list.stream().max(comparator).orElseThrow();
    }

    // MORE FLEXIBLE -- accepts Comparator, Comparator, etc.
    public static  T maxFlexible(List list, Comparator comparator) {
        return list.stream().max(comparator).orElseThrow();
    }

    public static void main(String[] args) {
        List integers = List.of(1, 2, 3);

        // sumRigid(integers); // COMPILE ERROR -- List is not List
        System.out.println(sumFlexible(integers)); // Output: 6.0 -- works!
    }
}

Practice 3: Prefer Generic Methods Over Wildcard Types in Return Values

Use wildcards in parameters (inputs) but avoid them in return types. A method that returns List<?> forces the caller to deal with an unknown type. Instead, use a type parameter so the caller gets a concrete type.

public class ReturnTypeBestPractice {

    // BAD -- caller gets List, which is almost useless
    public static List filterBad(List list) {
        return list; // Caller can't do much with List
    }

    // GOOD -- caller gets List, preserving the type information
    public static  List filterGood(List list, Predicate predicate) {
        return list.stream().filter(predicate).collect(Collectors.toList());
    }

    public static void main(String[] args) {
        List names = List.of("Alice", "Bob", "Charlie", "Anna");

        // filterGood returns List -- full type safety preserved
        List aNames = filterGood(names, n -> n.startsWith("A"));
        String first = aNames.get(0); // No cast needed
        System.out.println(first); // Output: Alice
    }
}

Practice 4: Use @SuppressWarnings("unchecked") Sparingly

Unchecked warnings exist for a reason -- they indicate a potential type safety hole. Only suppress them when you have carefully verified that the cast is safe, and always document why it is safe.

public class SuppressWarningsBestPractice {

    // BAD -- suppressing at class or method level hides all warnings
    // @SuppressWarnings("unchecked")
    // public  T[] toArray(List list) { ... }

    // GOOD -- suppress at the narrowest possible scope with a comment
    public static  T[] toArray(List list, Class componentType) {
        // Safe because we create the array with the correct component type
        @SuppressWarnings("unchecked")
        T[] array = (T[]) java.lang.reflect.Array.newInstance(componentType, list.size());
        return list.toArray(array);
    }

    public static void main(String[] args) {
        List names = List.of("Alice", "Bob", "Charlie");
        String[] nameArray = toArray(names, String.class);
        System.out.println(java.util.Arrays.toString(nameArray));
        // Output: [Alice, Bob, Charlie]
    }
}

Practice 5: Favor Generic Methods Over Raw Types for Interoperability

When you need to work with legacy code that uses raw types, wrap the interaction in a generic method that performs a checked cast, rather than spreading raw type usage throughout your code.

public class LegacyInteropBestPractice {

    // Legacy code returns raw List
    @SuppressWarnings("rawtypes")
    public static List getLegacyData() {
        List data = new java.util.ArrayList();
        data.add("Alice");
        data.add("Bob");
        return data;
    }

    // Wrapper method: isolates the raw type and unchecked cast
    @SuppressWarnings("unchecked") // Safe: we know getLegacyData() returns strings
    public static List getLegacyNames() {
        return (List) getLegacyData();
    }

    // Safer alternative: validate each element
    public static  List checkedCast(List raw, Class type) {
        List result = new ArrayList<>();
        for (Object item : raw) {
            result.add(type.cast(item)); // Throws ClassCastException if wrong type
        }
        return result;
    }

    public static void main(String[] args) {
        // All downstream code uses proper generics
        List names = getLegacyNames();
        names.forEach(name -> System.out.println(name.toUpperCase()));
        // Output: ALICE
        //         BOB

        // Extra-safe approach
        List verified = checkedCast(getLegacyData(), String.class);
        System.out.println("Verified: " + verified); // Output: Verified: [Alice, Bob]
    }
}

Best Practices Summary

# Practice Rationale
1 Always use parameterized types -- never raw types Raw types bypass compile-time type checking entirely
2 Use bounded wildcards in method parameters (PECS) Makes APIs more flexible for callers without sacrificing type safety
3 Avoid wildcards in return types Callers should receive concrete types, not unknowns
4 Narrow @SuppressWarnings scope and document why Prevents hiding legitimate warnings; maintains auditability
5 Isolate legacy raw-type interactions in wrapper methods Keeps raw types out of your main codebase
6 Prefer List over arrays for generic data Arrays are covariant and reified; generics are invariant and erased -- they do not mix well
7 Use diamond operator (<>) to reduce verbosity The compiler infers type arguments; no need to repeat them
8 Favor interfaces in type parameters (T extends Comparable<T>) Enables maximum implementation flexibility

13. Complete Practical Example: Type-Safe Generic Repository

Let us bring everything together with a real-world example that demonstrates generic classes, generic methods, bounded type parameters, wildcards, and generic interfaces working in concert. We will build a type-safe in-memory repository -- a pattern you will encounter in Spring Boot, JPA, and virtually every data-driven Java application.

This example is designed to show how generics enable you to write a single piece of infrastructure code that works with any entity type, while the compiler ensures that you never accidentally mix up your User operations with your Product operations.

import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

// ============================================================
// Step 1: Define a generic Identifiable interface
// ============================================================
// Any entity stored in our repository must have an ID
public interface Identifiable {
    ID getId();
    void setId(ID id);
}

// ============================================================
// Step 2: Define a generic Repository interface
// ============================================================
// T is the entity type, ID is the primary key type
// T must be Identifiable -- bounded type parameter
public interface CrudRepository, ID> {
    T save(T entity);
    Optional findById(ID id);
    List findAll();
    List findAll(Predicate filter);  // Wildcard: consumer of T
     List findAllMapped(Function mapper); // Generic method with wildcards
    boolean existsById(ID id);
    long count();
    void deleteById(ID id);
    void deleteAll();
}

// ============================================================
// Step 3: Implement a generic in-memory repository
// ============================================================
public class InMemoryCrudRepository>
        implements CrudRepository {

    private final Map store = new LinkedHashMap<>();
    private final AtomicLong sequence = new AtomicLong(1);

    @Override
    public T save(T entity) {
        if (entity.getId() == null) {
            entity.setId(sequence.getAndIncrement());
        }
        store.put(entity.getId(), entity);
        return entity;
    }

    @Override
    public Optional findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public List findAll() {
        return new ArrayList<>(store.values());
    }

    // Wildcard in Predicate: accepts Predicate, Predicate, etc.
    @Override
    public List findAll(Predicate filter) {
        return store.values().stream()
            .filter(filter)
            .collect(Collectors.toList());
    }

    // Generic method: caller decides the return type R
    @Override
    public  List findAllMapped(Function mapper) {
        return store.values().stream()
            .map(mapper)
            .collect(Collectors.toList());
    }

    @Override
    public boolean existsById(Long id) {
        return store.containsKey(id);
    }

    @Override
    public long count() {
        return store.size();
    }

    @Override
    public void deleteById(Long id) {
        store.remove(id);
    }

    @Override
    public void deleteAll() {
        store.clear();
    }
}

// ============================================================
// Step 4: Define concrete entity classes
// ============================================================
public class User implements Identifiable {
    private Long id;
    private String name;
    private String email;
    private int age;

    public User(String name, String email, int age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    @Override
    public Long getId() { return id; }

    @Override
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public String getEmail() { return email; }
    public int getAge() { return age; }

    @Override
    public String toString() {
        return "User{id=" + id + ", name='" + name + "', email='" + email + "', age=" + age + "}";
    }
}

public class Product implements Identifiable {
    private Long id;
    private String name;
    private double price;
    private String category;

    public Product(String name, double price, String category) {
        this.name = name;
        this.price = price;
        this.category = category;
    }

    @Override
    public Long getId() { return id; }

    @Override
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public double getPrice() { return price; }
    public String getCategory() { return category; }

    @Override
    public String toString() {
        return "Product{id=" + id + ", name='" + name + "', price=$" + price
            + ", category='" + category + "'}";
    }
}

// ============================================================
// Step 5: Build a generic service layer
// ============================================================
public class EntityService> {
    private final CrudRepository repository;

    public EntityService(CrudRepository repository) {
        this.repository = repository;
    }

    public T create(T entity) {
        return repository.save(entity);
    }

    public Optional getById(Long id) {
        return repository.findById(id);
    }

    public List getAll() {
        return repository.findAll();
    }

    // Uses bounded wildcard for maximum flexibility
    public List search(Predicate criteria) {
        return repository.findAll(criteria);
    }

    // Generic method with wildcard
    public  List extractField(Function extractor) {
        return repository.findAllMapped(extractor);
    }

    public boolean exists(Long id) {
        return repository.existsById(id);
    }

    public void delete(Long id) {
        repository.deleteById(id);
    }
}

// ============================================================
// Step 6: Putting it all together
// ============================================================
public class RepositoryDemo {
    public static void main(String[] args) {
        // Create typed repositories -- the compiler ensures type safety
        InMemoryCrudRepository userRepo = new InMemoryCrudRepository<>();
        InMemoryCrudRepository productRepo = new InMemoryCrudRepository<>();

        // Create typed services
        EntityService userService = new EntityService<>(userRepo);
        EntityService productService = new EntityService<>(productRepo);

        // --- User operations (type safe: only User methods available) ---
        userService.create(new User("Alice", "alice@example.com", 30));
        userService.create(new User("Bob", "bob@example.com", 25));
        userService.create(new User("Charlie", "charlie@example.com", 35));
        userService.create(new User("Diana", "diana@example.com", 28));

        System.out.println("=== All Users ===");
        userService.getAll().forEach(System.out::println);
        // Output:
        // User{id=1, name='Alice', email='alice@example.com', age=30}
        // User{id=2, name='Bob', email='bob@example.com', age=25}
        // User{id=3, name='Charlie', email='charlie@example.com', age=35}
        // User{id=4, name='Diana', email='diana@example.com', age=28}

        // Search with type-safe predicate
        System.out.println("\n=== Users over 28 ===");
        List seniorUsers = userService.search(u -> u.getAge() > 28);
        seniorUsers.forEach(System.out::println);
        // Output:
        // User{id=1, name='Alice', email='alice@example.com', age=30}
        // User{id=3, name='Charlie', email='charlie@example.com', age=35}

        // Extract specific fields -- generic method in action
        List emails = userService.extractField(User::getEmail);
        System.out.println("\n=== Email addresses ===");
        System.out.println(emails);
        // Output: [alice@example.com, bob@example.com, charlie@example.com, diana@example.com]

        // --- Product operations (completely independent, type safe) ---
        productService.create(new Product("Laptop", 999.99, "Electronics"));
        productService.create(new Product("Coffee Mug", 12.99, "Kitchen"));
        productService.create(new Product("Headphones", 149.99, "Electronics"));

        System.out.println("\n=== Electronics ===");
        List electronics = productService.search(p -> "Electronics".equals(p.getCategory()));
        electronics.forEach(System.out::println);
        // Output:
        // Product{id=1, name='Laptop', price=$999.99, category='Electronics'}
        // Product{id=3, name='Headphones', price=$149.99, category='Electronics'}

        // Extract product names
        List productNames = productService.extractField(Product::getName);
        System.out.println("\n=== Product names ===");
        System.out.println(productNames);
        // Output: [Laptop, Coffee Mug, Headphones]

        // The compiler prevents mixing types:
        // userRepo.save(new Product(...));   // COMPILE ERROR: Product is not User
        // productRepo.save(new User(...));   // COMPILE ERROR: User is not Product

        // Find by ID -- returns Optional, not Optional
        Optional found = userService.getById(1L);
        found.ifPresent(u -> System.out.println("\nFound: " + u.getName()));
        // Output: Found: Alice

        // Delete
        userService.delete(2L);
        System.out.println("\n=== After deleting Bob ===");
        System.out.println("User count: " + userRepo.count()); // Output: User count: 3
        System.out.println("Bob exists: " + userService.exists(2L)); // Output: Bob exists: false
    }
}

What This Example Demonstrates

Generic Feature Where It Appears
Generic interface Identifiable<ID>, CrudRepository<T, ID>
Bounded type parameter T extends Identifiable<ID> in CrudRepository
Generic class InMemoryCrudRepository<T>, EntityService<T>
Generic method <R> List<R> findAllMapped(...)
Upper bounded wildcard Function<? super T, ? extends R>
Lower bounded wildcard Predicate<? super T>
Diamond operator new InMemoryCrudRepository<>()
PECS principle Predicate consumes T (super), Function produces R (extends)
Type safety Cannot mix User and Product repositories

This is the power of generics: you write the repository logic once, and it works correctly and type-safely with User, Product, Order, or any entity you create in the future. The compiler guarantees that you cannot store a Product in a User repository, retrieve a User when you expected a Product, or pass the wrong type to a service method. All of these checks happen at compile time, with zero runtime overhead.

14. Quick Reference Cheat Sheet

Syntax Name Example
class Box<T> Generic class Box<String> box = new Box<>("hello");
<T> T method(T arg) Generic method String s = identity("hello");
<T extends Number> Upper bounded type parameter <T extends Comparable<T>> T max(T a, T b)
<T extends A & B> Multiple bounds <T extends Number & Comparable<T>>
? extends T Upper bounded wildcard List<? extends Number> -- read-only
? super T Lower bounded wildcard List<? super Integer> -- write-only
? Unbounded wildcard List<?> -- any type
<> Diamond operator new ArrayList<>() -- type inferred
PECS Producer Extends, Consumer Super Read = extends, Write = super



Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *