Java Generic Class: Syntax, Bounds, and Type Erasure
java generic class: Learn how to declare and use Java generic classes, including bounded type parameters, wildcards, and the implications of type erasure on runtime be...
A Java generic class lets you define a class with type parameters that are resolved at compile time. This preserves type safety while allowing a single implementation to serve many types. Without generics, you would either duplicate code or cast from Object, which shifts errors from compile time to runtime. The core idea is that the compiler knows the actual type when the class is instantiated, so it can verify that the code uses the type correctly before the program runs.
Declaring a Generic Class: Syntax and Type Parameters
Declaring a generic class is straightforward. You add a type parameter list in angle brackets after the class name. The most common convention is to use single uppercase letters: T for type, E for element, K and V for keys and values, and N for number. The type parameter is then used inside the class body as a placeholder for the actual type.
public class Box<T> { private T value; public Box(T value) { this.value = value; } public T getValue() { return value; } public void setValue(T value) { this.value = value; } }
When you instantiate the class, you supply the concrete type. From that point on, the compiler treats the object as if every occurrence of T were replaced by that concrete type.
Box<String> stringBox = new Box<>("hello"); String value = stringBox.getValue(); // no cast needed Box<Integer> integerBox = new Box<>(42); Integer intValue = integerBox.getValue();
The diamond operator <> in new Box<>("hello") lets the compiler infer the type from the constructor argument. If you omit the type argument on the left, the class becomes a raw type, which disables generic type checking and should be avoided in new code.
Bounded Type Parameters: Restricting What a Generic Can Accept
Sometimes you need to restrict the types that can be used as type arguments. For example, a numeric utility class might only accept Number and its subclasses. You can declare a bounded type parameter using the extends keyword. This bound can be a class or an interface, and it must be a subtype of the bound.
public class NumericWrapper<T extends Number> { private T number; public NumericWrapper(T number) { this.number = number; } public double doubleValue() { return number.doubleValue(); } }
Here T extends Number means the type argument must be Number or one of its subclasses like Integer, Double, or BigDecimal. The compiler rejects NumericWrapper<String> because String is not a Number. This restriction also allows you to call methods declared in the bound class, such as doubleValue(), without casting.
You can specify multiple bounds by using & between types. The first bound can be a class, and the remaining bounds must be interfaces. For instance, T extends Number & Comparable<T> requires the type to be both a Number and implement Comparable. This is useful when you need both numeric behavior and natural ordering.
Wildcards: Using Generic Types in Method Signatures
Wildcards appear when you want to write a method that accepts a generic type without knowing the exact type argument. The ? stands for an unknown type. The three forms are unbounded wildcard ?, upper-bounded wildcard ? extends T, and lower-bounded wildcard ? super T. Each serves a different purpose.
An unbounded wildcard is useful when the method only reads from a collection and does not care about the element type. For example, a method that prints every element of a List<?> can accept a List<String>, List<Integer>, or any other list.
public static void printAll(List<?> list) { for (Object item : list) { System.out.println(item); } }
An upper-bounded wildcard ? extends Number allows the method to accept a list of any subtype of Number. This is ideal for read-only operations where you want to call numeric methods.
public static double sumList(List<? extends Number> numbers) { double sum = .0; for (Number number : numbers) { sum += number.doubleValue(); } return sum; }
A lower-bounded wildcard ? super Integer is used when you want to write into a structure. For instance, a method that adds integers to a collection can accept a List<Integer>, List<Number>, or List<Object>, because all of them are supertypes of Integer. This follows the PECS principle: Producer Extends, Consumer Super.
public static void addIntegers(List<? super Integer> list) { list.add(1); list.add(2); }
Using wildcards in method signatures gives you flexibility while preserving type safety. The compiler enforces that you do not perform operations that could violate the type invariant.
Type Erasure: What the Compiler Actually Does
Java generics are implemented through type erasure. The compiler removes all type parameters and replaces them with their bounds, or with Object if no bound is declared. The resulting bytecode contains no generic information. This means that at runtime, a Box<String> and a Box<Integer> are both just Box objects.
Type erasure has several practical consequences. First, you cannot use a type parameter in a static context because the static field belongs to the class, not to a specific instantiation. For example, private static T instance; is not allowed. Second, you cannot create an array of a generic type, such as new T[10], because the runtime does not know T to create the array. Third, you cannot use instanceof with a generic type, such as if (obj instanceof Box<String>), because the type argument is erased.
Another consequence is that generic types are not reified. This means you cannot overload a method based solely on type arguments. For example, you cannot have both void process(List<String>) and void process(List<Integer>) in the same class, because after erasure both become process(List). The compiler rejects such overloads.
Understanding type erasure helps you reason about why certain patterns are illegal and why generic code sometimes requires unchecked casts when interacting with legacy non-generic code.
Common Pitfalls: Arrays, Raw Types, and Static Context
One of the most frequent mistakes is trying to create an array of a generic type. Because of type erasure, the runtime cannot guarantee that the the array will only contain elements of the declared type. The Java language forbids it outright. If you need a collection of generic objects, use ArrayList<T> or another collection class instead of an array.
Raw types are another pitfall. Using Box without a type argument turns off all generic checking. The compiler emits warnings, and you lose type safety. For example, you can assign a Box<String> to a raw Box and then put an Integer into it, causing a ClassCastException later when you try to read the value as a String. Always use parameterized types.
Static fields and methods cannot use the class type parameter. A static method must declare its own type parameters if it needs generics. For example, a static utility method can be generic independently of the class:
public static <T> T getFirst(List<T>> list) { return list.get(0); }
This is a generic method, not a generic class. The type parameter T is scoped to the method and is inferred from the the argument at the call site.
When to Choose a Generic Class Over Alternatives
Generic classes are not always the right tool. If the type variation is limited to a few known types, a class hierarchy with subclasses might be simpler. If you need to operate on objects without caring about their type, a method with a wildcard might suffice. The decision often comes down to whether the class itself needs to maintain type-specific state or behavior.
Use a generic class when you need to store or manipulate a value whose type is not known in advance, and you want the compiler to enforce consistency. For example, a Repository<T> that handles persistence for different entity types is a good fit. The same repository code can work for User, Order, or Product without duplic the logic.
On the other hand, if the class only performs operations that do not depend on the specific type, a non-generic class with wildcard methods might be simpler. Overusing generics can make code harder to read, especially when wildcards and bounds are nested deeply. Keep the type parameters meaningful and document the intended usage.
Runtime Behavior and Maintainability Considerations
Because of type erasure, generic classes have no runtime overhead compared to non-generic code. The compiler inserts casts where necessary, but these casts are no-ops when the type is correct. The main cost is at compile time, where the type checker does extra work. This means you can use generics liberally without worrying about performance degradation.
Maintainability improves because generic classes eliminate repetitive code and reduce the risk of ClassCastException. When you change the type of a collection or a field, the compiler catches all places that need adjustment. This is especially valuable in large codebases where a type change would otherwise require manual inspection of every usage.
However, generics can also reduce readability if overused. A class with multiple type parameters and complex bounds can become hard to follow. In such cases, consider whether the abstraction is worth the cognitive load. A simple non-generic implementation might be easier to maintain if the type variety is small and stable.
One practical pattern is to to use generic classes for data containers, such as Result<T> for a success or failure value, or Pair<K, V> for a key-value pair. These are self-documenting and provide compile-time safety. For more complex business logic, weigh the benefits of type safety against the complexity of the generic signature.