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:
No compile-time type checking -- the Integer 42 was silently added to a list that should only contain strings
Manual casting -- every retrieval requires (String) cast, which is tedious and error-prone
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 extends Number> 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 extends Number>?
List extends Number> 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
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 extends T> src, // Producer: extends
List super T> dest, // Consumer: super
Predicate super T> 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.
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 extends T>)
// -------------------------------------------------------
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 extends Number> 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 extends Number> 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 extends T> list, Comparator super T> 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 super T> 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
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 super T> filter); // Wildcard: consumer of T
List findAllMapped(Function super T, ? extends R> 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 super T> filter) {
return store.values().stream()
.filter(filter)
.collect(Collectors.toList());
}
// Generic method: caller decides the return type R
@Override
public List findAllMapped(Function super T, ? extends R> 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 super T> criteria) {
return repository.findAll(criteria);
}
// Generic method with wildcard
public List extractField(Function super T, ? extends R> 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.