Generic Classes in Java

Learn how to write reusable, type-safe data structures using type parameters like T, K, V in Java generic classes.

published: reading time: 19 min read author: Geek Workbench
Quick Summary

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 Object or 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> and Box<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

  1. Bounded type parameters for operations: T cannot be used in +/- operators. Bound with T extends Number to access Number methods like doubleValue().

  2. Generic array creation is illegal: new T[10] does not compile — use Object[] with a cast, or Array.newInstance(Class<T>, size).

  3. instanceof with generics does not work: if (obj instanceof Box<String>) is a compile error — the type argument is erased at runtime.

  4. Returning this from a generic method: If a method returns T, you cannot simply return this — this is the concrete subclass, not T. Use the self-bounded CRTP pattern with a self() 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> and Box<Integer> are the same class at runtime — type erasure means the String and Integer parts vanish
  • You cannot use instanceof Box<String>, create new T[], or pass generics into security checks — the runtime doesn’t know
  • Raw types (Box instead of Box<String>) sidestep all this safety — avoid them in new code
  • Primitives don’t work: no Box<int>, use Box<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

1. What is a generic class in Java?
A generic class declares type parameters at the class level. The compiler enforces type correctness at every call site.
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.

2. Can you use primitive types as type arguments for generics?
No. Primitives aren't objects in the JVM — they can't satisfy 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().

3. What happens if you use a raw type instead of a parameterized generic?
The compiler warns but lets it compile. You lose all type checking at compile time.
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.

4. Can two different instantiations of a generic class share the same runtime class?
Yes. Due to type erasure, all instantiations share the same runtime class.
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.

5. Can a generic class have multiple type parameters?
Yes. A generic class can declare any number of type parameters.
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.

6. What is the difference between <T> and <T extends Object>?
No difference — both erase to 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.
7. Why can't you create an array of a generic type like 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.

8. Can a generic class have multiple constructors with different type parameter usage?
Yes. Constructors don't declare their own type parameters — they use the class's. 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.
9. What is a generic pair and when would you use one?
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.

10. How can you prevent null values in a generic container?
Two common approaches: fail fast at set time, or make nullability explicit in the type.
// 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); }
}
11. Can you constrain a type parameter to a specific type hierarchy?
Yes. Bounded type parameters let you call type-specific methods.
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>.

12. How does implementing equals() work on a generic class?
After erasure, 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.

13. How does hashCode() work in a generic class?
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.

14. Can a generic class be declared final?
Yes. 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.

15. What are the naming conventions for type parameters?
Single uppercase letters by convention (JLS standard):
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.

16. How does type inference work with generic constructors?
The compiler infers the type argument from context.
// 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>
17. Can static methods in a generic class declare their own type parameters?
Yes. Static methods declare their own type parameters, independent of the class.
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.

18. What is a Class token and why is it useful with generics?
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
19. Can a generic class implement a generic interface?
Yes. Two valid patterns for implementing generic interfaces.
// 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
20. What is Optional in the context of generics?
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 T and ? super T for 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.

#java-abstract-classes #java #java-fundamentals

Arithmetic Operators in Java

Master Java arithmetic operators: addition, subtraction, multiplication, division, and modulo with integer division gotchas and operator precedence explained.

#java-arithmetic-operators #java #java-fundamentals

Array Basics in Java

Learn Java array fundamentals: declaration, initialization, element access, and the length property explained simply.

#java-array-basics #java #java-fundamentals