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.
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 (Java 1.4 and earlier), collections held Object — you could put anything into a List and the compiler would not complain. Retrieving an element required a cast to the actual type, and a mismatched cast threw ClassCastException at runtime. This was type-unsafe and verbose: the compiler could not help you, and runtime type errors were discovered in production rather than at compile time.
Generics fixed this by allowing classes to declare type parameters. A Box<T> is a box that holds elements of type T — the compiler tracks what T is at each usage and inserts casts automatically. If you try to put an Integer into a Box<String>, the code fails to compile with a clear error message, not a runtime crash. This is compile-time type safety, and it is one of the most impactful features for writing correct, maintainable Java code.
However, generics in Java are implemented via type erasure — the generic type information is removed at compile time, and all type parameters become Object or their bound type in the bytecode. This means Box<String> and Box<Integer> are the same class at runtime. Understanding erasure is essential for understanding the real behavior of generic code and the limitations that are not visible in source code alone.
This guide covers how to define and use generic classes, the type parameter conventions and naming standards, the common pitfalls from erasure and raw types, and the security and performance considerations that affect generic class design in production systems.
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
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 content
+set(T content)
+get() T
}
class Pair {
-K key
-V value
+getKey() K
+getValue() V
+of() Pair
}
class StringBox
class IntegerBox
StringBox --|> Box
IntegerBox --|> Box
Failure Scenarios
1. Raw Type Usage
Raw types predate Java 1.5 generics and exist as an escape hatch for backward compatibility. When Java 1.5 introduced generics, all existing pre-generics code continued to work unchanged because the compiler accepts raw types without requiring the generic parameter. This was the deliberate design choice that made generics adoptable without breaking the entire Java 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
Java generics are invariant by default. Box<String> is not a subtype of Box<Object> even though String is a subtype of Object. This is a deliberate design decision that prevents a category of runtime errors that would otherwise appear when collections with specific element types are treated as collections of their common supertype.
Box<Object> objBox = new Box<>();
objBox.set("text");
// Box<Object> is NOT the same as Box<String> — invariance
// Box<String> s = new Box<Object>(); // compile error
The intuition comes from collections. You can put an Integer into a List<Object>, so List<Integer> should logically be a subtype of List<Object> if generics were covariant like arrays. But if that were the case, you could do this:
List<Integer> ints = new ArrayList<>();
List<Object> objs = ints; // pretend this is legal
objs.add("string");
Integer i = ints.get(0); // ClassCastException — we put a String into what the runtime thinks is Integer
Arrays in Java do allow this unsafe covariance, which is why String[] is a subtype of Object[]. This leads to ArrayStoreException at runtime when you store a String into an Integer[] through an Object[] reference. Generics closed this hole by making List<String> non-assignable to List<Object>. The compiler catches the problem at the call site rather than letting it surface at runtime.
This invariance also applies at the method call level. You cannot pass a Box<String> where Box<Object> is expected, even though you could pass a String where Object is expected. The generic container itself carries the type parameter as part of its type, not just the elements inside it. The only way to get flexible assignability is through wildcards: Box<? extends Object> is conceptually equivalent to “some unknown Box that holds some unknown subtype of Object”, which is broad enough to accept Box<String>. But even then, the wildcard restricts what you can do with the contents.
3. Primitive Types Not Supported
Java generics do not accept primitive types as type arguments. Box<int> is a compile error, and Box<double> fails the same way. You must use the wrapper classes: Box<Integer>, Box<Double>, Box<Boolean>, and so on. This is not a quirk — it is a direct consequence of how generics are implemented in Java.
The root cause is type erasure. When the compiler processes Box<T>, it erases T to Object (or to the bound type if one exists). Object is a reference type — it holds pointers to objects on the heap, not raw values. Primitives like int, double, and boolean are not objects in the JVM; they have no object header, no identity, and no heap presence. You cannot assign an int to an Object reference, so an unbounded or Object-erased generic parameter cannot hold a primitive value.
This is why Java provides wrapper classes: Integer wraps an int on the heap, Double wraps a double, and so on. The wrapper is an object, so it satisfies the Object erasure target. When you call intBox.get(), the compiler inserts an implicit unboxing cast — ((Integer) content).intValue() — so the returned value feels natural at the call site. The performance cost is negligible with JIT optimization (the heap allocation can be eliminated in hot paths via scalarization), but it is worth knowing that autoboxing creates short-lived objects.
If you need primitive-keyed storage with minimal overhead, Java provides specialized alternatives. Int2ObjectMap from Trove or IntObjectMap from Fastutil offer primitive-key maps that avoid boxing entirely. The standard collections framework opts for simplicity and type safety over peak performance, which is the right trade-off for most code.
// 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
- Verify generic type arguments are consistent across the call chain
- Check for raw type warnings in static analysis (SpotBugs, CheckStyle)
- Ensure serialization frameworks handle generics correctly (Jackson
@JsonTypeInfo, Genson, etc.) - Add
equals()/hashCode()implementations that account for type parameters - Document type parameter contracts in Javadoc (
@param <T>)
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 wildcards needed for arithmetic:
Tcannot be used in+/-operators. If you need numeric operations, bound withNumberor use a strategy pattern. -
Generic array creation is illegal:
new T[10]does not compile — useObject[]and cast, or useArray.newInstance(). -
instanceofwith generics does not work:if (obj instanceof Box<String>)is a compile error.
Quick Recap
- Generic classes declare a type parameter section:
class Box<T> - Instantiation requires type arguments:
Box<String> - Type erasure removes generics at runtime — all
Box<T>becomeBox(raw type) - Raw types bypass type checking — avoid them
- Primitives require wrapper classes — no
Box<int>
Key Takeaways
Generic classes are the foundation of Java’s type-safe collections. A class like Box<T> lets you write one implementation that works with any reference type — the compiler enforces correct usage at compile time, eliminating ClassCastException risks in generic-aware code paths.
The key lesson from Box<T> is that generics are a compile-time contract, not a runtime one. Because of type erasure, Box<String> and Box<Integer> are the same class at runtime. This means you cannot use instanceof Box<String>, create new T[], or rely on the type argument for security decisions. The raw type is all the JVM sees.
Raw types are the escape hatch that undermines this safety net. Using Box instead of Box<String> silences the compiler and lets you insert any object. Avoid raw types in new code — the warning exists for a reason.
For a deeper look at how generic classes actually 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
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 form the foundation of type-safe data structures in Java. By declaring a type parameter like T or K, V, a single class definition becomes reusable across any reference type while letting the compiler enforce correct usage at every call site. This eliminates the defensive Object casts and ClassCastException risks that plagued pre-generics collection code.
The key trade-off is that generics are implemented via erasure — there is no runtime type information for Box<String> vs Box<Integer>. Both are just Box at runtime. This means you cannot use instanceof, new T(), or T.class with a generic type parameter. For cases where you need runtime type tokens, pass an explicit Class<T> object.
When designing generic classes, prefer bounded type parameters (<T extends Number>) only when the class actually needs to call type-specific methods. Using bounds unnecessarily restricts which types can instantiate your class. For continued learning on generics, 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.