Generic Classes in Java
Learn how to write reusable, type-safe data structures using type parameters like T, K, V in Java generic classes.
Learn how to write reusable, type-safe data structures using type parameters like T, K, V in Java generic classes. The guide uses practical examples to explain when to use generic classes, when not to use generic classes and shows how to apply the ideas in a Spring Boot project. It closes with common pitfalls and production checks so you can apply the pattern with fewer surprises.
Generic Classes in Java
Generic classes parameterize types using type parameters (<T>, <K, V>, etc.), enabling you to write a single class that works with multiple data types while maintaining compile-time type safety. Instead of Object casts scattered throughout your code, generics let the compiler enforce correct usage.
Introduction
Before generics, writing a Box class meant a choice: store everything as Object and cast at retrieval, or write separate StringBox, IntegerBox, RobotBox classes that duplicated logic. Generics collapse that tradeoff. Box<T> works with any reference type, and the compiler catches type mistakes at every call site.
The mechanism is called type erasure. When the compiler processes Box<T>, it replaces T with Object (or the leftmost bound type) and inserts the necessary casts at retrieval points. The JVM sees a single Box class, not a family of them. This means generics are a compile-time contract — the runtime has no awareness of the type argument. Box<String> and Box<Integer> are the same class at runtime.
This post walks through writing generic classes, when to use them versus plain classes, common failure patterns (raw types, type mismatch, primitives), and advanced patterns like the self-bounded CRTP for fluent builders.
When to Use Generic Classes
- Building collection classes —
List<T>,Map<K, V>,Set<T> - Writing utility wrappers that operate on any type
- Creating data holders that cache or buffer elements
- Implementing type-safe builders and fluent APIs
When NOT to Use Generic Classes
- The class behavior is identical across all types and no casting is needed — a plain class suffices
- You need to support primitive types directly (use wrapper classes or specialized implementations)
- Introducing generics adds unnecessary complexity for a one-off utility
- You need to serialize to JSON/XML with frameworks that struggle with generics (check framework support first)
Code Example: A Simple Generic Box
public class Box<T> {
private T content;
public void set(T content) {
this.content = content;
}
public T get() {
return content;
}
}
// Usage
Box<String> stringBox = new Box<>();
stringBox.set("Hello"); // type-safe
String value = stringBox.get(); // no cast needed
// This would be a COMPILE ERROR — type mismatch:
// stringBox.set(42); // ❌ Box<String> cannot hold an Integer
Code Example: A Generic Pair
public class Pair<K, V> {
private K key;
private V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() { return key; }
public V getValue() { return value; }
public static <K, V> Pair<K, V> of(K k, V v) {
return new Pair<>(k, v);
}
}
// Usage
Pair<String, Integer> entry = Pair.of("age", 30);
Mermaid Diagram: Generic Class Hierarchy
classDiagram
class Box~T~ {
-T content
+set(T content)
+get() T
}
class Pair~K, V~ {
-K key
-V value
+getKey() K
+getValue() V
+of() Pair
}
class NumericBox~T extends Number~ {
+T getNumeric()
}
Box~T~ <|-- NumericBox~T extends Number~
Common Mistakes
1. Raw Type Usage
Raw types predate generics and exist as an escape hatch for backward compatibility. When Java 1.5 introduced generics, all existing code kept working because the compiler accepts raw types without requiring the generic parameter. This made generics adoptable without breaking the ecosystem.
// WARNING: raw type - defeats the purpose of generics
Box rawBox = new Box();
rawBox.set("Hello");
rawBox.set(42); // compiles but no type check - danger!
Integer val = (Integer) rawBox.get(); // ClassCastException at runtime if wrong
Fix: Always use parameterized types: Box<String>.
The raw type Box is the same class that Box<T> compiles to after erasure — it is the erased form. Using it bypasses all generic type checking at the call site. The compiler still emits a warning (unchecked conversion), but it does not prevent the code from compiling. In legacy codebases that predate Java 5, raw types appear everywhere in the standard library: ArrayList, HashMap, Iterator without type parameters. When interoperating with such code, you may be forced to use raw types at the boundary, but the rule is to parameterize everywhere inside your own code.
Raw types also appear in legitimate patterns such as Class<?>. Class is a raw type when written as Class rather than Class<?>, and this is intentional — Class is a generic class where the type parameter is only used at the call site. String.class has type Class<String>, and passing it to a method that expects Class<?> is safe. The raw Class without diamonds appears in older APIs that were never retrofitted with generics, and in reflective code where the type token is passed explicitly. The distinction matters: Box<String> as a raw type loses the String parameter and becomes just Box, while Class as a raw type still carries the type token in the Class.forName() and newInstance() patterns.
2. Type Mismatch at Call Site
Generics are invariant — Box<String> is not a Box<Object>, even though String extends Object. This is intentional. If it weren’t, you could do this:
List<Integer> ints = new ArrayList<>();
List<Object> objs = ints; // ❌ compile error
objs.add("string");
Integer i = ints.get(0); // ClassCastException — if this were allowed
Arrays have this exact problem, which is why String[] can cause ArrayStoreException when you store through an Object[] reference. Generics sealed this hole. When you need flexibility, use wildcards like Box<? extends Object> — but they come with their own constraints on what you can read or write.
3. Primitive Types Not Supported
Java generics don’t accept primitives — Box<int> is a compile error. Use Box<Integer>, Box<Double>, Box<Boolean> instead. Why? Erasure replaces T with Object, and primitives aren’t objects in the JVM. No object header, no heap presence, no way to assign int to Object.
Wrappers fix this: Integer wraps an int on the heap. When you call intBox.get(), the compiler inserts an unboxing cast — ((Integer) content).intValue(). JIT optimization usually eliminates the allocation cost in hot paths, so you don’t need to worry much in practice.
If you genuinely need primitive-key maps without the wrapper overhead, Trove’s Int2ObjectMap or Fastutil’s IntObjectMap exist for that. Most code doesn’t need that level of tuning.
// Box<int> box = new Box<>(); // compile error - int is not a reference type
Box<Integer> intBox = new Box<>(); // use wrapper Integer instead
Trade-Off Table
| Aspect | Generic Approach | Raw Type / Object Approach |
|---|---|---|
| Type safety | Compile-time enforced | Runtime ClassCastException risk |
| Code verbosity | Slightly more at declaration | Less at declaration, more at cast sites |
| Performance | No runtime penalty (erasure) | No runtime penalty |
| Refactor safety | Rename type param is safe | Rename field risks casts breaking |
| IDE support | Full autocompletion for T |
Limited — IDE sees Object |
Observability Checklist
- Generic type arguments are consistent across the call chain
- No raw type warnings in static analysis (SpotBugs, CheckStyle)
- Serialization frameworks handle generics correctly
-
equals()/hashCode()account for type parameters - Type parameter contracts documented in Javadoc
Security Notes
- Deserialization attacks: Untrusted data deserialized into
Objector raw types can trigger unsafe casts. Generics provide no runtime protection — use input validation and allowlists. - Type parameter not enforced at runtime: Because of type erasure,
Box<String>andBox<Integer>are the same class at runtime. Do not rely on generics for security decisions. - Reflection:
Class.forName(parametricType)with untrusted input can load arbitrary classes. Validate classnames against an allowlist.
Pitfalls
-
Bounded type parameters for operations:
Tcannot be used in+/-operators. Bound withT extends Numberto accessNumbermethods likedoubleValue(). -
Generic array creation is illegal:
new T[10]does not compile — useObject[]with a cast, orArray.newInstance(Class<T>, size). -
instanceofwith generics does not work:if (obj instanceof Box<String>)is a compile error — the type argument is erased at runtime. -
Returning
thisfrom a generic method: If a method returnsT, you cannot simply returnthis—thisis the concrete subclass, notT. Use the self-bounded CRTP pattern with aself()helper.
Self-Bounded Types and the Curiously Recurring Template Pattern
The CRTP pattern uses a self-bounded type parameter: class Builder<T extends Builder<T>>. Borrowed from C++ templates, it lets methods return the concrete subtype instead of the generic base — so PersonBuilder.withName() returns PersonBuilder, not Builder<?>. This gives you fluent chaining without separate interfaces for every builder subclass.
public abstract class Builder<T extends Builder<T>> {
protected T self() {
@SuppressWarnings("unchecked")
T result = (T) this;
return result;
}
public T withName(String name) {
return self(); // override in subclass to add field
}
public T withValue(int value) {
return self();
}
}
public class PersonBuilder extends Builder<PersonBuilder> {
private String name;
private int value;
@Override
public PersonBuilder withName(String name) {
this.name = name;
return this;
}
@Override
public PersonBuilder withValue(int value) {
this.value = value;
return this;
}
public Person build() {
return new Person(name, value);
}
}
The self() method casts this back to T. The @SuppressWarnings(“unchecked”) is unavoidable here — the cast is logically sound (the type parameter guarantees this is some subtype of Builder) but the compiler can’t verify it.
Why not just return this? Because this is a PersonBuilder, not a T. Returning this from a method declared as T doesn’t work — the self() helper bridges that gap.
When to use CRTP in generics:
- Building fluent APIs where each method must return the concrete subtype (builder pattern, step builders)
- Implementing self-typed comparison chains where each
thenCompare()call returns the most specific comparator - Creating dominated builders in frameworks like Hibernate Validator or JAXB RI
Limitations:
- The
@SuppressWarnings(“unchecked”)on the cast is a code smell — static analyzers like SpotBugs may flag it - Deep inheritance hierarchies with CRTP become hard to follow
- The pattern does not compose well with intersection bounds (
<T extends Builder<T> & Cloneable>)
Key Takeaways
Here’s what sticks with you after working with generics long enough:
Box<String>andBox<Integer>are the same class at runtime — type erasure means theStringandIntegerparts vanish- You cannot use
instanceof Box<String>, createnew T[], or pass generics into security checks — the runtime doesn’t know - Raw types (
Boxinstead ofBox<String>) sidestep all this safety — avoid them in new code - Primitives don’t work: no
Box<int>, useBox<Integer>instead
For a deeper look at how generic classes disappear at compile time, see Type Erasure in Java Generics. For writing flexible methods that operate on generic types, Generic Methods in Java covers static utility patterns and type inference.
Interview Questions
class Box<T> {
T content;
T get() { return content; }
void set(T item) { this.content = item; }
}
// Type-safe instantiation
Box<String> stringBox = new Box<>();
String val = stringBox.get(); // no cast needed
stringBox.set(42); // compile error!
The compiler knows get() returns a String when you call it on a Box<String>. Trying to set(42) on a Box<String> fails at compile time, not runtime.
Object erasure.
// Box<int> box = new Box<>(); // compile error!
Box<Integer> box = new Box<>();
box.set(42); // auto-boxed to Integer
int val = box.get(); // auto-unboxed to int
Generics erase T to Object, and primitives have no heap presence. The compiler inserts unboxing casts at retrieval: ((Integer) content).intValue().
Box rawBox = new Box(); // raw type - compiler warns
rawBox.set("hello");
rawBox.set(42); // allowed! no type check
rawBox.set(new Object());
Integer val = (Integer) rawBox.get(); // ClassCastException if wrong type
You can throw anything into a raw Box. Get the cast wrong at retrieval and you get a ClassCastException at runtime.
Box<String> stringBox = new Box<>();
Box<Integer> intBox = new Box<>();
// At runtime, both are just: class Box
stringBox.getClass() == intBox.getClass(); // true!
The type parameter is compile-time only. This is why instanceof Box<String> is illegal — there’s no separate Box<String> class at runtime.
class Entry<K, V, R> {
K key;
V value;
R metadata;
}
// Usage
Entry<String, Integer, Boolean> entry = new Entry<>();
entry.key = "age";
entry.value = 30;
entry.metadata = true;
Common conventions: K/V for key/value, T for a single type, E for elements, R for return types.
<T> and <T extends Object>?Object. <T extends Object> makes the intent clearer (reference types only) but <T> is the standard convention. Use <T extends Object> only when a framework or linter requires it.new T[10]?new T[10] doesn't compile. Arrays carry runtime type information that generics don't have.
// new T[10] doesn't work - T erases to Object
// new Object[10] is created instead - wrong type
// Correct approach: use Class token
T[] array = (T[]) Array.newInstance(componentType, size);
// Example with Class<T>
String[] arr = (String[]) Array.newInstance(String.class, 10);
The Class<T> token reifies the type at runtime, allowing Array.newInstance to create the correct array type.
Pair<K, V> can have Pair(K key, V value) and Pair(Pair<K, V> other) — both valid, both use the class-level K and V.Pair<K, V> is a simple two-element holder for related values.
Pair<String, Integer> entry = Pair.of("age", 30);
String key = entry.getKey(); // "age"
Integer val = entry.getValue(); // 30
Use it when a method needs to return two values. For production code, Map.Entry<K, V> from the JDK has clearer semantics and richer methods.
// Approach 1: Objects.requireNonNull() - fail fast
class NonNullBox<T> {
T content;
void set(T item) {
this.content = Objects.requireNonNull(item);
}
}
// Approach 2: Optional<T> - nullability explicit in type
class OptionalBox<T> {
Optional<T> content = Optional.empty();
void set(T item) { this.content = Optional.of(item); }
}
class NumericBox<T extends Number> {
T content;
double toDouble() {
return content.doubleValue(); // OK - Number has this method
}
}
// Usage
NumericBox<Integer> intBox = new NumericBox<>();
NumericBox<Double> dblBox = new NumericBox<>();
double val = intBox.toDouble(); // calls Integer.doubleValue()
Multiple bounds use &: <T extends Number & Comparable>.
equals(T obj) becomes equals(Object obj). Check the runtime type internally.
class Pair<K, V> {
K key;
V value;
@Override
public boolean equals(Object o) {
if (!(o instanceof Pair)) return false;
Pair<?, ?> other = (Pair<?, ?>) o;
return Objects.equals(key, other.key) && Objects.equals(value, other.value);
}
@Override
public int hashCode() {
return Objects.hash(key, value);
}
}
Use instanceof Pair or getClass() inside — you can’t rely on the generic parameter after erasure. Always override both equals() and hashCode() when storing in collections.
hashCode() is declared on Object, so erasure doesn't affect its signature.
class Box<T> {
T content;
@Override
public int hashCode() {
// content.hashCode() works if T extends Object
// which it always does after erasure
return content != null ? content.hashCode() : 0;
}
}
If your hashCode() calls a method on T, that method must be in the bound or in Object. Since all generic types erase to Object (or a subclass), and hashCode() is on Object, calling t.hashCode() on an unbounded T always works.
final and <T> are independent modifiers — one doesn't affect the other.
public final class ImmutablePair<K, V> {
private final K key;
private final V value;
// ... methods
}
// Can still use different type arguments:
ImmutablePair<String, Integer> p1 = new ImmutablePair<>("a", 1);
ImmutablePair<Object, Object> p2 = new ImmutablePair<>(new Object(), new Object());
final means no subclassing, but instantiation with any type argument works fine. The type parameters only exist at compile time.
| Letter | Meaning |
|---|---|
T |
Type |
K / V |
Key / Value |
E |
Element |
N |
Number |
R |
Return type |
class Map<K, V> { } // typical Map
class List<E> { } // typical List
class Future<T, R> { } // typical Future with return type
T and K/V cover most use cases. Multi-letter names are valid but uncommon in idiomatic Java.
// From constructor argument
Box<String> b1 = new Box<>("hello"); // infers Box<String>
// From left side (diamond syntax)
Box<String> b2 = new Box<>(); // infers Box<String>
Box<> b3 = new Box<>(); // compile error - can't infer
// Explicit when inference is ambiguous
List<String> list = new ArrayList<>(); // infers ArrayList<String>
class Box<T> {
T content;
// This static method has its own <T> - independent of the class's T
public static <T> Box<T> of(T value) {
Box<T> box = new Box<>();
box.set(value);
return box;
}
}
// Usage
Box<String> stringBox = Box.of("hello"); // Box<String>
Box<Integer> intBox = Box.of(42); // Box<Integer>
The method’s <T> is scoped only to that method. The class can be non-generic and the method would still work.
Class<T> is a reifiable type token that carries T to runtime.
// T.class is illegal - erasure removes T
// String.class is Class<String> - the type is reified
// Creating instances with Class<T>
Class<String> stringClass = String.class;
String instance = stringClass.getDeclaredConstructor().newInstance();
// Generic array creation
String[] array = (String[]) Array.newInstance(String.class, 10);
// Type-safe casting
Object obj = "hello";
String s = stringClass.cast(obj); // no ClassCastException
// Pattern 1: Concrete type - class is NOT generic
class StringList implements List<String> {
public String get(int i) { /* ... */ }
public void add(String s) { /* ... */ }
}
// Pattern 2: Deferred to class's own parameter
class AnyList<T> implements List<T> {
public T get(int i) { /* ... */ }
public void add(T item) { /* ... */ }
}
List<String> strings = new StringList(); // concrete type
List<Integer> ints = new AnyList<>(); // deferred to T
Optional<T> represents a value that may be absent, without using null.
Optional<String> opt = Optional.of("hello");
// Safe chaining - empty case handled
String result = opt
.map(String::toUpperCase)
.filter(s -> s.length() > 3)
.orElse("default");
// Getting the value explicitly
opt.isPresent(); // true
opt.get(); // "hello"
opt.orElse("default"); // "hello"
opt.orElseGet(() -> computeDefault()); // lazy default
Further Reading
- Generic Methods — writing flexible methods with type parameters
- Wildcards —
? extends Tand? super Tfor flexible type ranges - Type Erasure — how generics are implemented at compile time
- Type Bounds — constraining type parameters with upper and lower bounds
- Bridge Methods — compiler-generated methods from type erasure
- Oracle: Generic Types — official documentation on generic class declarations
Conclusion
Generic classes are the backbone of Java’s type-safe collections. A single Box<T> declaration works with any reference type while the compiler catches type mismatches at compile time — no more casting from Object and hoping for the best.
One rule worth remembering: only bound type parameters when you actually need to call type-specific methods. <T extends Number> is useful when you want doubleValue(); unbounded <T> is simpler when you don’t need those methods.
For continued learning, see Generic Methods in Java which covers generic method declarations, type inference, and bounded type parameters in depth.
Category
Related Posts
Abstract Classes in Java
Learn about partially implemented classes that define contracts for subclasses using abstract methods and concrete implementations.
Arithmetic Operators in Java
Master Java arithmetic operators: addition, subtraction, multiplication, division, and modulo with integer division gotchas and operator precedence explained.
Array Basics in Java
Learn Java array fundamentals: declaration, initialization, element access, and the length property explained simply.