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.

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 classesList<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

AspectGeneric ApproachRaw Type / Object Approach
Type safetyCompile-time enforcedRuntime ClassCastException risk
Code verbositySlightly more at declarationLess at declaration, more at cast sites
PerformanceNo runtime penalty (erasure)No runtime penalty
Refactor safetyRename type param is safeRename field risks casts breaking
IDE supportFull autocompletion for TLimited — 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 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 wildcards needed for arithmetic: T cannot be used in +/- operators. If you need numeric operations, bound with Number or use a strategy pattern.

  2. Generic array creation is illegal: new T[10] does not compile — use Object[] and cast, or use Array.newInstance().

  3. instanceof with 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> become Box (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

1. What is a generic class in Java?
A generic class is a class defined with one or more type parameters (e.g., `class Box`). It lets you write reusable, type-safe code without casts. Instead of storing everything as `Object` and casting at retrieval, generics let the compiler verify types at compile time, eliminating `ClassCastException` risks in generic-aware code paths."

2. Can you use primitive types as type arguments for generics?
No. Java generics do not support primitive types — only reference types. `Box` is a compile error. You must use wrapper classes: `Box`. This is because generics are implemented via erasure, and the erased type must be `Object` or a subclass, which primitives are not."

3. What happens if you use a raw type instead of a parameterized generic?
The compiler accepts it with a warning, but you lose compile-time type checking. You can put any object into a raw `Box`, and retrieving it requires an unchecked cast. If the wrong type is inserted, you get a `ClassCastException` at runtime rather than a compile error. It also undermines IDE autocompletion for the stored type."

4. Can two different instantiations of a generic class share the same runtime class?
Yes. Due to type erasure, all `Box` instances are just `Box` (raw type) at runtime, regardless of what `T` is. The type argument is a compile-time fiction. This is why `instanceof Box` is illegal — there is no `Box` class at runtime to check against."

5. Can a generic class have multiple type parameters?
Yes. A generic class can declare any number of type parameters in its definition: `class Entry { ... }`. Each parameter is independent, though the usual naming conventions apply (`K` for key, `V` for value, `T` for single type, etc.)."

6. What is the difference between `` and ``?
There is no functional difference — both unbounded declarations produce the same erased type (`Object`). However, `` makes the intent explicit: you intend `T` to be a reference type but are not constraining it further. In practice, `` is preferred for brevity; `` is rarely used unless documentation or a framework requires it."

7. Why can't you create an array of a generic type like `new T[10]`?
Because at runtime, after type erasure, `T` becomes `Object` (or the leftmost bound), so `new T[10]` would resolve to `new Object[10]` — the wrong type. Additionally, arrays carry runtime type information about their element type, which would be lost if you could instantiate a reified generic array. The standard workaround is `Array.newInstance(Class, size)` with an explicit `Class` token, which reifies the type at runtime."

8. Can a generic class have multiple constructors with different type parameter usage?
Yes. A generic class can have multiple constructors, each using the class's type parameter(s) in different ways. For example, `Pair` can have `Pair(K key, V value)` and `Pair(Pair other)` — both are valid. Constructors do not declare their own type parameters; they use the ones declared on the class itself."

9. What is a generic pair and when would you use one?
A generic pair (`Pair`) is a simple data holder that stores two related values of potentially different types. It is useful for returning two values from a method, mapping keys to values in simple contexts, or as a building block for more complex generic structures. For production code, prefer specialized types (e.g., `Map.Entry` from the JDK) over ad-hoc pairs, as the semantics are clearer."

10. How can you prevent null values in a generic container?
You can use `Objects.requireNonNull()` in the setter, or bound the type parameter with a custom `NonNull` marker interface. A cleaner approach is using `Optional` as the field type rather than `T` — this makes nullability explicit at the call site and forces callers to handle the absent case. Another option is a dedicated `NonNullBox` class that validates on set and throws `NullPointerException` if a null is passed."

11. Can you constrain a type parameter to a specific type hierarchy?
Yes. `class NumericBox` restricts `T` to `Number` or any subclass (`Integer`, `Double`, `BigDecimal`, etc.). This lets you call `Number` methods like `doubleValue()` inside the class. The same rules apply as with methods — multiple bounds use `&`, and the first bound (if a class) sets the erasure base type."

12. How does implementing equals() work on a generic class?
When implementing `equals(T obj)` on a generic class, the signature after erasure becomes `equals(Object obj)`. Your `equals` implementation should handle the type check internally (using `instanceof` or `getClass()`) rather than using the generic parameter as the check type, because `Box.equals` and `Box.equals` both override `Object.equals`. Always override both `equals()` and `hashCode()` together when storing generic objects in collections."

13. How does hashCode() work in a generic class?
`hashCode()` is declared on `Object` with signature `int hashCode()` — no generic parameter. So a generic class's `hashCode()` method does not need a bridge and is not affected by erasure in signature. However, if your `hashCode()` implementation calls a method on `T`, that method must be accessible after erasure (i.e., declared in the bound or in `Object`). For example, using `t.hashCode()` on an unbounded `T` works because `hashCode()` is on `Object`."

14. Can a generic class be declared final?
Yes. `final` and generics are independent modifiers. A `final` generic class cannot be extended, but it can still be instantiated with different type arguments. For example, `java.util.Collections.SingletonMap` is final and generic. The type parameter exists only at compile time; at runtime, the class is just the raw type."

15. What are the naming conventions for type parameters?
The Java convention (from the JLS) is single uppercase letters: `T` for type, `K` for key, `V` for value, `E` for element, `N` for number, `R` for return type. Multi-letter names like `Key` or `Value` are used informally but are not JLS-compliant. Descriptive multi-letter names (like `CustomType`) are valid but uncommon in idiomatic Java."

16. How does type inference work with generic constructors?
The compiler can often infer the type argument from the context — `new Box<>("hello")` infers `String`. In diamond syntax `new Box<>()`, the compiler infers from the declared type on the left side if available. For chained calls or ambiguous contexts, explicit `` may be needed. Type inference also works through method arguments: `List list = new ArrayList<>()` infers `ArrayList`."

17. Can static methods in a generic class declare their own type parameters?
Yes. Static methods in any class — generic or not — can declare their own type parameters. The type parameter on a static method is independent of any class-level type parameter. For example, `public static T of(T value)` in a non-generic class is valid. The method's type parameter is scoped only to that method and cannot be accessed from the class's instance members."

18. What is a Class token and why is it useful with generics?
`Class` is a reifiable type token that carries `T` at runtime. Because erasure removes generic type information, `T.class` is illegal, but you can pass `String.class` (which is `Class`) to a method that needs to know the type at runtime. This enables `newInstance()`, `cast()`, `asSubclass()`, and `Array.newInstance()`. The caller controls the token, so the method gets a concrete, reifiable type rather than an erased one."

19. Can a generic class implement a generic interface?
Yes. A generic class can implement a generic interface while using its own type parameter: `class StringList implements List`. Here `StringList` is not generic itself — it implements the concrete parameterized type `List`. Alternatively, `class AnyList implements List` uses the class's own type parameter for the interface. Both patterns are valid."

20. What is Optional in the context of generics?
`Optional` is a generic class designed to represent the presence or absence of a value of type `T` without using `null`. It provides `map()`, `flatMap()`, `filter()`, and `orElse()` methods that propagate the generic type safely. Using `Optional` in APIs forces callers to handle the absent case explicitly and eliminates ambiguous null returns. It is a value-based class (not a collection) but follows similar generic patterns."


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 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.

#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