Java Generics — Complete Guide with Examples
In this tutorial, you'll learn about Java Generics. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Java Generics let classes and methods operate on types specified by the caller, providing compile-time type safety without runtime overhead through type erasure.
What You'll Learn
You'll understand why generics exist, how to write generic classes and methods, use bounded type parameters and wildcards, navigate the subtleties of type erasure, and apply generics in real-world patterns like type-safe collections and data processing pipelines.
Why Generics Matter
Before generics, collections held Object references, requiring manual casting that could fail at runtime with ClassCastException. Generics shift these errors to compile time, catching type mismatches before deployment. DodaTech's data processing pipeline uses generics extensively — a Result<T> type that carries both data and metadata, and generic Repository methods that work with any entity type without casting.
Real-World Use: Type-Safe Configuration Registry
Imagine a configuration system where different settings have different types: timeout: int, hostname: String, enabled: boolean. Without generics, you'd store everything as Object and cast on retrieval. With generics, you write ConfigRegistry.get("timeout", Integer.class) once and it's type-safe forever. DodaTech uses this pattern in its device management platform.
Generics Learning Path
flowchart LR A[Java OOP] --> B[Java Collections] B --> C[Java Generics] C --> D[Generic Classes] C --> E[Generic Methods] C --> F[Bounded Type Parameters] C --> G[Wildcards] D --> H[Java Lambdas & Streams] E --> I[Custom Data Structures] C:::current classDef current fill:#3b82f6,color:#fff,stroke:#333,stroke-width:2px
Why Generics? The Problem They Solve
import java.util.*;
public class WithoutGenerics {
public static void main(String[] args) {
// Pre-generics style — everything is Object
List devices = new ArrayList();
devices.add("Router-01");
devices.add("Switch-02");
devices.add(12345); // Oops — integer mixed in!
for (Object obj : devices) {
// Cast — will fail at runtime on the integer
String device = (String) obj;
System.out.println("Device: " + device);
}
}
}
Expected output:
Device: Router-01
Device: Switch-02
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
With generics, this error is caught at compile time:
public class WithGenerics {
public static void main(String[] args) {
List<String> devices = new ArrayList<>();
devices.add("Router-01");
devices.add("Switch-02");
// devices.add(12345); // COMPILE ERROR — type safe!
for (String device : devices) { // No cast needed
System.out.println("Device: " + device);
}
}
}
Expected output:
Device: Router-01
Device: Switch-02
The List<String> declaration tells the compiler: "this list can only hold strings." The compiler enforces this, eliminating an entire class of runtime errors.
Generic Classes
A generic class uses a type parameter in angle brackets. Think of T as a placeholder for the actual type that gets filled in when you create an instance.
// Generic container — stores a value and its timestamp
public class TimestampedValue<T> {
private final T value;
private final long timestamp;
public TimestampedValue(T value) {
this.value = value;
this.timestamp = System.currentTimeMillis();
}
public T getValue() {
return value;
}
public long getTimestamp() {
return timestamp;
}
public boolean isExpired(long ttlMillis) {
return System.currentTimeMillis() - timestamp > ttlMillis;
}
}
public class GenericClassDemo {
public static void main(String[] args) throws InterruptedException {
// T is replaced with String
TimestampedValue<String> configValue =
new TimestampedValue<>("192.168.1.1");
System.out.println("Config: " + configValue.getValue());
// T is replaced with Integer — completely type-safe
TimestampedValue<Integer> portValue =
new TimestampedValue<>(8080);
System.out.println("Port: " + portValue.getValue());
// Check expiry
Thread.sleep(100);
System.out.println("Expired? " + configValue.isExpired(50));
}
}
Expected output:
Config: 192.168.1.1
Port: 8080
Expired? true
The same TimestampedValue class works with String, Integer, or any other reference type. The compiler generates appropriate bytecode for each usage through type erasure.
Generic Methods
Sometimes only a method needs to be generic, not the entire class. Generic methods declare their type parameters before the return type.
import java.util.*;
public class GenericMethodDemo {
public static void main(String[] args) {
String[] deviceNames = {"Router-A", "Switch-B", "Firewall-C"};
Integer[] portNumbers = {22, 80, 443, 8080};
// Call generic method with different types
String firstDevice = firstElement(deviceNames);
Integer firstPort = firstElement(portNumbers);
System.out.println("First device: " + firstDevice);
System.out.println("First port: " + firstPort);
// Type-safe array-to-list conversion
List<String> deviceList = arrayToList(deviceNames);
System.out.println("Devices: " + deviceList);
}
// Generic method — <T> before return type
public static <T> T firstElement(T[] array) {
if (array == null || array.length == 0) {
return null;
}
return array[0];
}
// Another generic method with type inference
public static <T> List<T> arrayToList(T[] array) {
List<T> list = new ArrayList<>();
for (T element : array) {
list.add(element);
}
return list;
}
}
Expected output:
First device: Router-A
First port: 22
Devices: [Router-A, Switch-B, Firewall-C]
The <T> before the return type declares the type parameter. The compiler infers T from the argument type — no explicit type passing is needed.
Bounded Type Parameters
Bounded type parameters restrict what types can be used as arguments. Use extends to specify an upper bound.
import java.util.*;
public class BoundedTypeDemo {
public static void main(String[] args) {
List<Integer> signalLevels = Arrays.asList(-50, -65, -80, -95);
List<Double> temperatures = Arrays.asList(36.5, 37.0, 38.2);
// Works with Integer (Number subclass)
System.out.println("Avg signal: " + average(signalLevels));
// Works with Double (Number subclass)
System.out.println("Avg temp: " + average(temperatures));
// Would NOT compile — String is not a Number
// List<String> names = Arrays.asList("a", "b");
// System.out.println(average(names));
}
// T must be a Number or subclass — bounded by 'extends Number'
public static <T extends Number> double average(List<T> numbers) {
double sum = 0.0;
for (T num : numbers) {
sum += num.doubleValue();
}
return sum / numbers.size();
}
}
Expected output:
Avg signal: -72.5
Avg temp: 37.233333333333334
<T extends Number> ensures only Number and its subclasses (Integer, Double, Long, etc.) can be used. The compiler rejects String or other unrelated types.
Wildcards — ? for Unknown Types
Wildcards handle cases where the exact type is unknown. Use ? extends T for producers (read) and ? super T for consumers (write) — this is the PECS rule (Producer-Extends, Consumer-Super).
import java.util.*;
public class WildcardDemo {
public static void main(String[] args) {
List<Integer> intList = Arrays.asList(1, 2, 3);
List<Double> doubleList = Arrays.asList(1.5, 2.5, 3.5);
List<Object> objectList = new ArrayList<>();
// Upper-bounded wildcard — can read Integers or Doubles
System.out.println("Sum of ints: " + sumOfNumberList(intList));
System.out.println("Sum of doubles: " + sumOfNumberList(doubleList));
// Lower-bounded wildcard — can write Integers into Object list
addAll(intList, objectList);
System.out.println("Object list: " + objectList);
}
// Upper-bounded wildcard — read any Number subclass
public static double sumOfNumberList(List<? extends Number> list) {
double sum = 0;
for (Number n : list) {
sum += n.doubleValue();
}
return sum;
}
// Lower-bounded wildcard — write Integers to any Integer supertype
public static void addAll(List<Integer> source, List<? super Integer> dest) {
for (Integer item : source) {
dest.add(item);
}
}
// Unbounded wildcard — any type, read-only
public static void printList(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
}
}
Expected output:
Sum of ints: 6.0
Sum of doubles: 7.5
Object list: [1, 2, 3]
PECS rule explained: If a collection provides data to your code (producer), use ? extends T. If your code provides data to a collection (consumer), use ? super T. If both, don't use a wildcard.
Type Erasure — What Happens at Runtime
Java Generics are a compile-time feature. The compiler removes (erases) generic type information and inserts casts where needed. This preserves backward compatibility with pre-generics code.
import java.util.*;
public class TypeErasureDemo {
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
// At runtime, both are just ArrayList — no generic info
System.out.println("Same class? " +
strings.getClass().equals(integers.getClass()));
// Reflection confirms — generic types are erased
System.out.println("String list type: " +
strings.getClass().getName());
}
}
Expected output:
Same class? true
String list type: java.util.ArrayList
What type erasure means:
- You cannot use
instanceofwith generic types:if (obj instanceof List<String>)— compile error - You cannot create generic arrays:
new T[10]— compile error - Static fields are shared across all type instantiations
- Bridge methods are generated to maintain polymorphism
What Is the PECS Rule in Java Generics?
PECS stands for "Producer-Extends, Consumer-Super." When a collection produces elements you read, use ? extends T. When a collection consumes elements you write, use ? super T. If you do both reading and writing, use an exact type T.
Common Mistakes Beginners Make
Using raw types:
Listinstead ofList<String>turns off generic type safety and causes unchecked warnings. The compiler can't help you catch type errors.Cannot create arrays of parameterized types:
new List<String>[10]doesn't compile because type erasure makes runtime array store checks impossible. UseArrayList<List<String>>instead.Cannot use
instanceofwith generic types:if (obj instanceof List<String>)is illegal. Useif (obj instanceof List<?>)and cast with a warning suppression.Confusing
? extends Tand? super T: Using the wrong wildcard direction causes compilation errors. Remember PECS: Producer Extends, Consumer Super.Over-complicating with multiple bounds:
<T extends A & B & C>works but is hard to read and maintain. Consider interfaces and composition instead of complex bounds.Generic static methods confusion: Static methods cannot use the class-level type parameter. They need their own
<T>declaration before the return type.Forgetting that primitives don't work:
List<int>doesn't compile. UseList<Integer>with autoboxing. Java's automatic conversion betweenintandIntegerhandles this transparently.
Practice Questions
- What is the difference between
List<?>andList<Object>? - Why does
new E[10]not compile in a generic class? - What does type erasure do?
- When would you use
? super Tinstead of? extends T? - Can a generic method have multiple type parameters?
Answers:
List<?>is a list of an unknown type — you can only read from it (asObject).List<Object>is a list specifically typed to holdObjectand its subclasses — you can both read and write.- Because type erasure removes the generic type information at runtime, the JVM cannot perform array store checks. The compiler prevents this to avoid heap pollution.
- Type erasure removes generic type parameters after compilation, replacing them with their bounds (or
Objectif unbounded). It inserts casts where needed. This preserves backward compatibility with pre-generics bytecode. - Use
? super Twhen you're writing elements into a collection (consumer). For example, addingIntegervalues to aList<? super Integer>that could beList<Integer>,List<Number>, orList<Object>. - Yes, separate them with commas:
<T, U, V>. For example, a method that takesMap<T, U>and returnsList<V>needs three type parameters.
Challenge
Build a generic Cache<K, V> class with time-based expiration using TimestampedValue<V>. Support get(K key), put(K key, V value, long ttlMs), and cleanup() that removes expired entries. Use bounded wildcards in a method that copies entries from one cache to another.
Real-World Task
Create a generic Result<T> class that wraps a value or an error, similar to Rust's Result type. Include map(), flatMap(), and orElse() methods. Then build a pipeline of data processing steps — each step returns Result<T> — and chain them together. This is the pattern DodaTech uses for fault-tolerant data processing in its analytics pipeline.
FAQ
What's Next
Related topics: Java, Java Collections, Java OOP, Functional Programming, Spring
Generics are one of those topics that seem confusing at first but become invisible once you understand them. Write one generic class and one generic method today — your future self will thank you for the reusable, type-safe code.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro