Java Generic Classes
Java Generic Classes
A generic class is a class that declares one or more type parameters in angle brackets after the class name. Those parameters act as placeholders for the actual types the caller supplies when creating an instance. class Stack<T> does not know whether T will be String, Order, or BigDecimal — and it does not need to. The class is written once, the type is decided by whoever uses it, and the compiler verifies that every usage is consistent. This is how ArrayList<E>, HashMap<K,V>, and Optional<T> in the JDK work — one class definition, used safely with any type.
What Is a Generic Class?
A generic class is declared by appending a type parameter list in angle brackets immediately after the class name. The parameters can then appear anywhere a type name would appear inside the class — as field types, constructor parameter types, method return types, and local variable types.
SYNTAX:
[access modifier] class ClassName<T> {
// T can be used as a type anywhere inside this class body
}
[access modifier] class ClassName<T, U> {
// Two type parameters - T and U are independent placeholders
}
[access modifier] class ClassName<T extends SomeSupertype> {
// Bounded type parameter - T must be SomeSupertype or a subtype
// (bounded type parameters are covered in their own article)
}
INSTANTIATING A GENERIC CLASS:
ClassName<String> instance = new ClassName<>(); // T = String
ClassName<Integer> instance2 = new ClassName<>(); // T = Integer
// The diamond <> on the right lets the compiler infer the type
// argument from the declared type on the left. This is type inference.
// ClassName<String> instance = new ClassName<String>() also compiles
// but is redundant - the compiler already knows T = String.
Basic Overview - What Declaring a Generic Class Gives You
TYPE PARAMETER AS A PLACEHOLDER
Fresher view : <T> in the class declaration is like a blank you
fill in when you create an object. Box<String> fills
T with String. Box<Integer> fills T with Integer.
The same class works for both.
Deeper view : the compiler creates NO new class for each type
argument. Box<String> and Box<Integer> share the
same compiled Box.class file (type erasure). The
type argument exists only in the compiler's checking
phase - not in the compiled bytecode.
FIELDS, METHODS, AND CONSTRUCTORS USING T
Fresher view : wherever you would normally write String or Integer,
you write T instead. The compiler substitutes the
real type everywhere T appears when you instantiate.
Deeper view : in bytecode, T becomes Object (or its declared
bound). The compiler inserts CHECKCAST instructions
at read sites so the runtime type is still verified,
but those casts cannot fail if the code compiled
without unchecked warnings.
MULTIPLE TYPE PARAMETERS
Fresher view : a class can have two or more type parameters, each
filling a different role. Pair<K,V> has K for the
key and V for the value - caller decides both.
Deeper view : each parameter is independent. Pair<String,Integer>
and Pair<Integer,String> are different parameterized
types even though they use the same two raw types.
The order and roles of the parameters are fixed by
the declaration.
WHAT GENERIC CLASSES CANNOT DO
Fresher view : you cannot write new T(), T.class, T[] newArray =
new T[10], or use T in a static field or method.
These limitations come from type erasure - T is
not available at runtime.
Deeper view : these restrictions exist because after erasure T
is gone from the compiled class. new T() would need
to know which constructor to call; T.class would
need a distinct Class object; T[] would need to
know the component type of the array - none of
which survive into the bytecode.
Declaring a Single-Type-Parameter Class
The simplest generic class has one type parameter, typically named T. Every usage of T in the class body is resolved to the actual type argument when an instance is created.
1// File: Stack.java
2
3import java.util.ArrayList;
4import java.util.EmptyStackException;
5import java.util.List;
6
7// Stack<T> - a generic last-in-first-out stack.
8// T can be any reference type: String, Integer, Order, or any class.
9// The class is written once and works safely for all of them.
10public class Stack<T> {
11
12 private final List<T> elements = new ArrayList<>();
13
14 // T appears as the parameter type - push accepts exactly T
15 public void push(T element) {
16 elements.add(element);
17 }
18
19 // T appears as the return type - pop returns exactly T, no cast needed
20 public T pop() {
21 if (isEmpty()) {
22 throw new EmptyStackException();
23 }
24 return elements.remove(elements.size() - 1);
25 }
26
27 // T appears in the return type as a type argument to another generic type
28 public T peek() {
29 if (isEmpty()) {
30 throw new EmptyStackException();
31 }
32 return elements.get(elements.size() - 1);
33 }
34
35 public boolean isEmpty() {
36 return elements.isEmpty();
37 }
38
39 public int size() {
40 return elements.size();
41 }
42
43 @Override
44 public String toString() {
45 return "Stack" + elements.toString();
46 }
47}1// File: StackDemo.java
2
3public class StackDemo {
4
5 public static void main(String[] args) {
6
7 System.out.println("=== Stack<String> - browser history simulation ===");
8 Stack<String> history = new Stack<>();
9 history.push("https://meesho.com");
10 history.push("https://meesho.com/electronics");
11 history.push("https://meesho.com/product/laptop-stand");
12
13 System.out.println("Current: " + history.peek()); // String - no cast
14 System.out.println("Back : " + history.pop()); // String - no cast
15 System.out.println("Back : " + history.pop()); // String - no cast
16 System.out.println("Size : " + history.size());
17
18 System.out.println();
19
20 System.out.println("=== Stack<Integer> - undo operation stack ===");
21 Stack<Integer> undoStack = new Stack<>();
22 undoStack.push(1);
23 undoStack.push(2);
24 undoStack.push(3);
25
26 while (!undoStack.isEmpty()) {
27 int operationId = undoStack.pop(); // Integer auto-unboxed to int - no cast
28 System.out.println(" Undoing operation: " + operationId);
29 }
30
31 System.out.println();
32
33 // Compile-time enforcement - these lines would fail to compile:
34 // history.push(42); <- int is not String
35 // int wrong = history.pop(); <- String is not int
36 System.out.println("Compile-time safety: type mismatches caught before running");
37 }
38}Output:
=== Stack<String> - browser history simulation ===
Current: https://meesho.com/product/laptop-stand
Back : https://meesho.com/product/laptop-stand
Back : https://meesho.com/electronics
Size : 1
=== Stack<Integer> - undo operation stack ===
Undoing operation: 3
Undoing operation: 2
Undoing operation: 1
Compile-time safety: type mismatches caught before running
Declaring a Multiple-Type-Parameter Class
A class can declare two or more type parameters when different roles in the class require independent type flexibility. The JDK's Map<K,V> is the canonical example - one parameter for keys, a completely independent parameter for values.
1// File: Result.java
2
3// Result<T, E> models a computation that either succeeds with a value
4// of type T or fails with an error of type E.
5// T and E are INDEPENDENT - a Result<Order, PaymentException> and a
6// Result<Order, NetworkException> are different parameterized types.
7public class Result<T, E extends Throwable> {
8
9 private final T value;
10 private final E error;
11 private final boolean success;
12
13 private Result(T value, E error, boolean success) {
14 this.value = value;
15 this.error = error;
16 this.success = success;
17 }
18
19 // Static factory methods with their own type parameters
20 public static <T, E extends Throwable> Result<T, E> success(T value) {
21 return new Result<>(value, null, true);
22 }
23
24 public static <T, E extends Throwable> Result<T, E> failure(E error) {
25 return new Result<>(null, error, false);
26 }
27
28 public boolean isSuccess() { return success; }
29 public T getValue() { return value; }
30 public E getError() { return error; }
31
32 @Override
33 public String toString() {
34 return success
35 ? "Result.success(" + value + ")"
36 : "Result.failure(" + error.getMessage() + ")";
37 }
38}1// File: ResultDemo.java
2
3import java.io.IOException;
4
5public class ResultDemo {
6
7 static class OrderConfirmedException extends Exception {
8 OrderConfirmedException(String msg) { super(msg); }
9 }
10
11 // Returns Result<String, IOException> - success carries String orderId,
12 // failure carries IOException. The method signature is self-documenting.
13 static Result<String, IOException> fetchOrderId(boolean shouldSucceed) {
14 if (shouldSucceed) {
15 return Result.success("ORD-8801");
16 }
17 return Result.failure(new IOException("Network timeout fetching order"));
18 }
19
20 // Returns Result<Double, OrderConfirmedException>
21 static Result<Double, OrderConfirmedException> getOrderTotal(String orderId) {
22 if (orderId.startsWith("ORD-")) {
23 return Result.success(3499.0);
24 }
25 return Result.failure(
26 new OrderConfirmedException("Order " + orderId + " not confirmed"));
27 }
28
29 public static void main(String[] args) {
30
31 System.out.println("=== Successful pipeline ===");
32 Result<String, IOException> idResult = fetchOrderId(true);
33 System.out.println(idResult);
34
35 if (idResult.isSuccess()) {
36 String orderId = idResult.getValue(); // String - no cast
37 Result<Double, OrderConfirmedException> totalResult = getOrderTotal(orderId);
38 System.out.println(totalResult);
39
40 if (totalResult.isSuccess()) {
41 double total = totalResult.getValue(); // Double auto-unboxed - no cast
42 System.out.println("Final amount: Rs." + total);
43 }
44 }
45
46 System.out.println();
47
48 System.out.println("=== Failed pipeline ===");
49 Result<String, IOException> failedId = fetchOrderId(false);
50 System.out.println(failedId);
51
52 if (!failedId.isSuccess()) {
53 IOException error = failedId.getError(); // IOException - no cast
54 System.out.println("Error type : " + error.getClass().getSimpleName());
55 System.out.println("Error msg : " + error.getMessage());
56 }
57 }
58}Output:
=== Successful pipeline ===
Result.success(ORD-8801)
Result.success(3499.0)
Final amount: Rs.3499.0
=== Failed pipeline ===
Result.failure(Network timeout fetching order)
Error type : IOException
Error msg : Network timeout fetching order
Static Members in Generic Classes
Static fields and static methods in a generic class cannot use the class's type parameters. This restriction follows directly from how type erasure works: a static member belongs to the class itself, not to any instance, and all parameterized instances of a generic class share the same class object at runtime. There is no way for a static field to be String for Box<String> and Integer for Box<Integer> simultaneously — there is only one field.
1// File: StaticMembersDemo.java
2
3public class StaticMembersDemo {
4
5 static class Counter<T> {
6
7 // Instance fields CAN use T - each instance has its own T bound
8 private T lastValue;
9 private int count;
10
11 // Static field CANNOT use T - this field is shared across
12 // ALL parameterized types of Counter. There is no T here.
13 private static int totalInstances = 0;
14
15 // The following would NOT COMPILE if uncommented:
16 // private static T sharedValue; // ERROR: non-static type variable T
17 // // cannot be referenced from a static context
18
19 Counter(T initialValue) {
20 this.lastValue = initialValue;
21 this.count = 0;
22 totalInstances++; // static field - shared across all Counter<*> instances
23 }
24
25 void record(T value) {
26 this.lastValue = value;
27 this.count++;
28 }
29
30 T getLastValue() { return lastValue; }
31 int getCount() { return count; }
32
33 // Static method CANNOT use the CLASS's T.
34 // It CAN declare its OWN type parameter (covered in Generic Methods article).
35 static int getTotalInstances() {
36 return totalInstances;
37 }
38 }
39
40 public static void main(String[] args) {
41
42 Counter<String> stringCounter = new Counter<>("initial");
43 Counter<Integer> intCounter = new Counter<>(0);
44 Counter<String> anotherString = new Counter<>("start");
45
46 stringCounter.record("first");
47 stringCounter.record("second");
48 intCounter.record(42);
49
50 System.out.println("stringCounter last: " + stringCounter.getLastValue());
51 System.out.println("stringCounter count: " + stringCounter.getCount());
52
53 System.out.println("intCounter last: " + intCounter.getLastValue());
54
55 // totalInstances is shared - all three Counter<*> objects incremented it
56 System.out.println("Total instances created: " + Counter.getTotalInstances());
57 // Prints 3 - not 1 per parameterized type - because all share one static field
58 }
59}Output:
stringCounter last: second
stringCounter count: 2
intCounter last: 42
Total instances created: 3
Generic Classes Implementing Interfaces
A generic class can implement a generic interface in two ways: it can pass its own type parameter to the interface (remaining generic), or it can supply a concrete type argument (becoming non-generic). Both patterns appear throughout the JDK.
1// File: GenericInterfaceImplementation.java
2
3import java.util.ArrayList;
4import java.util.List;
5
6public class GenericInterfaceImplementation {
7
8 // A generic interface
9 interface Printable<T> {
10 void print(T item);
11 List<T> getAll();
12 }
13
14 // PATTERN 1 - pass the type parameter through.
15 // PrintableList<T> stays generic - the caller decides T.
16 // PrintableList<String> is a Printable<String>.
17 // PrintableList<Integer> is a Printable<Integer>.
18 static class PrintableList<T> implements Printable<T> {
19 private final List<T> items = new ArrayList<>();
20
21 void add(T item) { items.add(item); }
22
23 @Override
24 public void print(T item) {
25 System.out.println(" -> " + item);
26 }
27
28 @Override
29 public List<T> getAll() {
30 return List.copyOf(items);
31 }
32 }
33
34 // PATTERN 2 - supply a concrete type argument.
35 // StringPrinter is no longer generic - T is fixed to String.
36 // StringPrinter is always a Printable<String>.
37 static class StringPrinter implements Printable<String> {
38 private final List<String> log = new ArrayList<>();
39
40 @Override
41 public void print(String item) {
42 log.add(item);
43 System.out.println(" [StringPrinter] " + item.toUpperCase());
44 }
45
46 @Override
47 public List<String> getAll() {
48 return List.copyOf(log);
49 }
50 }
51
52 public static void main(String[] args) {
53
54 System.out.println("=== Pattern 1: PrintableList<String> ===");
55 PrintableList<String> categoryList = new PrintableList<>();
56 categoryList.add("Electronics");
57 categoryList.add("Clothing");
58 categoryList.add("Home & Kitchen");
59 categoryList.getAll().forEach(categoryList::print);
60
61 System.out.println();
62
63 System.out.println("=== Pattern 1: PrintableList<Integer> ===");
64 PrintableList<Integer> idList = new PrintableList<>();
65 idList.add(101);
66 idList.add(202);
67 idList.getAll().forEach(idList::print);
68
69 System.out.println();
70
71 System.out.println("=== Pattern 2: StringPrinter (T fixed to String) ===");
72 StringPrinter printer = new StringPrinter();
73 printer.print("order received");
74 printer.print("payment confirmed");
75 System.out.println("Log entries: " + printer.getAll().size());
76 }
77}Output:
=== Pattern 1: PrintableList<String> ===
-> Electronics
-> Clothing
-> Home & Kitchen
=== Pattern 1: PrintableList<Integer> ===
-> 101
-> 202
=== Pattern 2: StringPrinter (T fixed to String) ===
[StringPrinter] ORDER RECEIVED
[StringPrinter] PAYMENT CONFIRMED
Log entries: 2
Real-World Example - Zepto Inventory Generic Repository and Builder
A grocery delivery platform's inventory service needs generic data-access objects for multiple entity types - Product, Category, Warehouse - and a generic builder for creating well-formed request objects. Both patterns appear constantly in real codebases: the repository abstracts persistence operations, the builder enforces required fields before creating an instance. Making both generic means one implementation serves every entity type cleanly.
1// File: Entity.java
2
3// Marker interface - all entities in the system have an ID of some type
4public interface Entity<ID> {
5 ID getId();
6}1// File: Product.java
2
3public class Product implements Entity<String> {
4 private final String productId;
5 private final String name;
6 private final String category;
7 private final double pricePerUnit;
8 private final int stockQty;
9
10 public Product(String productId, String name, String category,
11 double pricePerUnit, int stockQty) {
12 this.productId = productId;
13 this.name = name;
14 this.category = category;
15 this.pricePerUnit = pricePerUnit;
16 this.stockQty = stockQty;
17 }
18
19 @Override public String getId() { return productId; }
20 public String getName() { return name; }
21 public String getCategory() { return category; }
22 public double getPricePerUnit() { return pricePerUnit; }
23 public int getStockQty() { return stockQty; }
24
25 @Override
26 public String toString() {
27 return "Product[" + productId + ", " + name
28 + ", Rs." + pricePerUnit + ", qty=" + stockQty + "]";
29 }
30}1// File: InMemoryRepository.java
2
3import java.util.*;
4
5// T must implement Entity<ID> - this ensures every stored object has
6// an ID of type ID, which the repository uses for lookup and deletion.
7// This is a bounded type parameter: T extends Entity<ID>.
8public class InMemoryRepository<T extends Entity<ID>, ID> {
9
10 private final Map<ID, T> store = new LinkedHashMap<>();
11
12 public void save(T entity) {
13 store.put(entity.getId(), entity);
14 }
15
16 public Optional<T> findById(ID id) {
17 return Optional.ofNullable(store.get(id));
18 }
19
20 public List<T> findAll() {
21 return List.copyOf(store.values());
22 }
23
24 public boolean existsById(ID id) {
25 return store.containsKey(id);
26 }
27
28 public void delete(ID id) {
29 store.remove(id);
30 }
31
32 public int count() {
33 return store.size();
34 }
35}1// File: SearchRequest.java
2
3import java.util.ArrayList;
4import java.util.List;
5import java.util.Objects;
6
7// Generic builder - T is the type of object being built.
8// Builder<T> accumulates optional settings; build() produces a T.
9// This pattern separates construction from the built type.
10public class SearchRequest<T> {
11
12 private final String query;
13 private final int page;
14 private final int pageSize;
15 private final List<String> filters;
16 private final String sortField;
17
18 private SearchRequest(Builder<T> builder) {
19 this.query = builder.query;
20 this.page = builder.page;
21 this.pageSize = builder.pageSize;
22 this.filters = List.copyOf(builder.filters);
23 this.sortField = builder.sortField;
24 }
25
26 public String getQuery() { return query; }
27 public int getPage() { return page; }
28 public int getPageSize() { return pageSize; }
29 public List<String> getFilters() { return filters; }
30 public String getSortField() { return sortField; }
31
32 @Override
33 public String toString() {
34 return "SearchRequest[query='" + query + "', page=" + page
35 + ", pageSize=" + pageSize + ", filters=" + filters
36 + ", sortField='" + sortField + "']";
37 }
38
39 // Static generic builder class - T indicates WHAT is being searched for.
40 // The type parameter communicates intent to the caller without restricting behavior.
41 public static class Builder<T> {
42 private final String query; // required
43 private int page = 1;
44 private int pageSize = 20;
45 private final List<String> filters = new ArrayList<>();
46 private String sortField = "relevance";
47
48 public Builder(String query) {
49 this.query = Objects.requireNonNull(query, "query must not be null");
50 }
51
52 public Builder<T> page(int page) {
53 this.page = page;
54 return this;
55 }
56
57 public Builder<T> pageSize(int pageSize) {
58 this.pageSize = pageSize;
59 return this;
60 }
61
62 public Builder<T> filter(String filter) {
63 this.filters.add(filter);
64 return this;
65 }
66
67 public Builder<T> sortBy(String field) {
68 this.sortField = field;
69 return this;
70 }
71
72 public SearchRequest<T> build() {
73 return new SearchRequest<>(this);
74 }
75 }
76}1// File: ZeptoInventoryDemo.java
2
3import java.util.List;
4
5public class ZeptoInventoryDemo {
6
7 public static void main(String[] args) {
8
9 // InMemoryRepository<Product, String> - Product is the entity, String is the ID type
10 InMemoryRepository<Product, String> productRepo = new InMemoryRepository<>();
11
12 productRepo.save(new Product("ZPT-001", "Full Cream Milk 1L", "Dairy", 68.0, 240));
13 productRepo.save(new Product("ZPT-002", "Atta 5kg", "Grains", 279.0, 80));
14 productRepo.save(new Product("ZPT-003", "Bananas (6 pcs)", "Fruits", 45.0, 160));
15 productRepo.save(new Product("ZPT-004", "Greek Yogurt 400g", "Dairy", 95.0, 110));
16
17 System.out.println("=== Repository operations ===");
18 System.out.println("Total products: " + productRepo.count());
19
20 productRepo.findById("ZPT-002").ifPresent(p ->
21 System.out.println("Found: " + p));
22
23 productRepo.findById("ZPT-999").ifPresentOrElse(
24 p -> System.out.println("Found: " + p),
25 () -> System.out.println("ZPT-999: not found"));
26
27 productRepo.delete("ZPT-003");
28 System.out.println("After delete: " + productRepo.count() + " products");
29
30 System.out.println();
31
32 System.out.println("=== Generic SearchRequest<Product> builder ===");
33 SearchRequest<Product> dairySearch = new SearchRequest.Builder<Product>("dairy products")
34 .page(1)
35 .pageSize(10)
36 .filter("category:Dairy")
37 .filter("inStock:true")
38 .sortBy("price")
39 .build();
40
41 System.out.println(dairySearch);
42
43 System.out.println();
44
45 System.out.println("=== Filtering products from repository ===");
46 List<Product> dairyProducts = productRepo.findAll().stream()
47 .filter(p -> p.getCategory().equals("Dairy"))
48 .toList();
49
50 System.out.println("Dairy products matching search:");
51 dairyProducts.forEach(p ->
52 System.out.println(" " + p.getName() + " - Rs." + p.getPricePerUnit()));
53 }
54}Output:
=== Repository operations ===
Total products: 4
Found: Product[ZPT-002, Atta 5kg, Rs.279.0, qty=80]
ZPT-999: not found
After delete: 3 products
=== Generic SearchRequest<Product> builder ===
SearchRequest[query='dairy products', page=1, pageSize=10, filters=[category:Dairy, inStock:true], sortField='price']
=== Filtering products from repository ===
Dairy products matching search:
Full Cream Milk 1L - Rs.68.0
Greek Yogurt 400g - Rs.95.0
InMemoryRepository<T extends Entity<ID>, ID> is one class that serves Product today and will serve Category, Warehouse, or any Entity tomorrow without a single line of change to the repository itself. SearchRequest<T> communicates to readers which type is being searched for - SearchRequest<Product> is unambiguous - while the fluent builder enforces that query is always supplied. This is the same shape as Spring Data JPA's JpaRepository<T, ID> and popular builder libraries.
Generic Class - Summary Diagram
GENERIC CLASS ANATOMY:
public class Box<T> { <- class name + type parameter list
|
+-- 'T' is the placeholder
resolved at instantiation: Box<String>, Box<Integer>
private T content; <- T used as field type
public Box(T content) { <- T used as constructor parameter type
this.content = content;
}
public T get() { <- T used as method return type
return content;
}
public void set(T newContent) { <- T used as method parameter type
this.content = newContent;
}
// NOT ALLOWED - cannot use T in static context:
// private static T defaultValue; // compile error
// public static T create() { } // compile error
// ALLOWED - static method with its OWN type parameter:
public static <E> Box<E> of(E item) { // E is a NEW parameter, not the class's T
return new Box<>(item);
}
}
INSTANTIATION:
Box<String> stringBox = new Box<>("Laptop"); // T = String throughout
Box<Integer> integerBox = new Box<>(4999); // T = Integer throughout
Box<Box<String>> nested = new Box<>(stringBox); // T = Box<String>
AT RUNTIME (after type erasure):
All of the above are instances of Box.class.
Box<String>.class does NOT exist as a separate Class object.
The T information guided compile-time checking only.
Best Practices
Keep type parameter lists as short as possible. One type parameter is the most common case. Two are needed for key-value pairs, result types, or bidirectional mappings. Three or more almost always signal that the class is doing too much, or that some parameters can be expressed through a bounded type parameter rather than a free variable.
Use meaningful single-letter names following the convention. T for a general type, E for element, K and V for key and value, R for result. If the role of a type parameter is not clear from a conventional single letter, use a descriptive full name - EntityType, IdType - rather than two-letter abbreviations like TT or TV which look like typos.
Do not use generic classes where the type argument is always the same concrete type. A Repository<Product, String> that is only ever used with Product and String might be better expressed as a dedicated ProductRepository. Generic classes earn their place when multiple parameterizations actually occur - otherwise the generality adds complexity without benefit.
Prefer static factory methods over public constructors for generic classes when the return type needs inference. Box.of("hello") is shorter and equally readable than new Box<String>("hello") - the diamond operator handles the latter case too, but Box.of("hello") often reads more naturally and is how Optional.of(), List.of(), and Map.of() in the JDK work.
Common Mistakes
Mistake 1 - Using the Class's Type Parameter in a Static Member
1// WRONG - T belongs to instances of GenericCache, not to the class itself.
2// A static field shared across ALL instances of GenericCache<*> cannot
3// have a type that varies per parameterization.
4class GenericCacheBroken<T> {
5 private static T defaultValue; // COMPILE ERROR
6 // "non-static type variable T cannot be referenced from a static context"
7
8 public static T getDefault() { return defaultValue; } // COMPILE ERROR
9}
10
11// CORRECT - static members use either a concrete type or their OWN
12// separate type parameter declared on the method
13class GenericCacheFixed<T> {
14 private static Object fallback = null; // concrete type - shared is fine
15
16 // Own type parameter <E> - unrelated to the class's T
17 public static <E> GenericCacheFixed<E> empty() {
18 return new GenericCacheFixed<>();
19 }
20}Mistake 2 - Trying to Create an Instance of the Type Parameter With new T()
1// WRONG - T is erased at runtime. The JVM has no idea which
2// constructor to call for new T(). This is a COMPILE ERROR.
3class ObjectPool<T> {
4 public T createNew() {
5 return new T(); // COMPILE ERROR: cannot instantiate type T
6 }
7}
8
9// CORRECT - pass a factory or Supplier that knows how to create T
10import java.util.function.Supplier;
11
12class ObjectPoolFixed<T> {
13 private final Supplier<T> factory;
14
15 ObjectPoolFixed(Supplier<T> factory) {
16 this.factory = factory;
17 }
18
19 public T createNew() {
20 return factory.get(); // factory knows the actual type - T does not
21 }
22}
23
24// Usage:
25// ObjectPoolFixed<StringBuilder> pool = new ObjectPoolFixed<>(StringBuilder::new);Mistake 3 - Creating a Generic Array With new T[n]
1import java.util.Arrays;
2
3// WRONG - generic arrays cannot be created directly.
4// new T[size] is a COMPILE ERROR because the array's component type
5// (T) is not known at runtime after erasure.
6class TypedBuffer<T> {
7 private T[] buffer;
8
9 TypedBuffer(int size) {
10 buffer = new T[size]; // COMPILE ERROR: generic array creation
11 }
12}
13
14// CORRECT OPTION A - use a List instead of an array
15import java.util.ArrayList;
16import java.util.List;
17
18class ListBuffer<T> {
19 private final List<T> buffer;
20
21 ListBuffer(int size) {
22 buffer = new ArrayList<>(size); // List handles the erasure internally
23 }
24
25 void add(T item) { buffer.add(item); }
26 T get(int index) { return buffer.get(index); }
27}
28
29// CORRECT OPTION B - use an Object array with a suppressed cast
30// (the standard JDK approach for internal array-backed generic structures)
31class ArrayBuffer<T> {
32 private final Object[] buffer; // Object[] - the erased form of T[]
33
34 ArrayBuffer(int size) {
35 buffer = new Object[size]; // Object[] is always safe to create
36 }
37
38 void set(int index, T item) { buffer[index] = item; }
39
40 @SuppressWarnings("unchecked")
41 T get(int index) {
42 return (T) buffer[index]; // unchecked cast - safe because only T is ever stored
43 }
44}Mistake 4 - Expecting instanceof to Work With a Parameterized Type
1import java.util.ArrayList;
2import java.util.List;
3
4// WRONG - the type argument is erased. At runtime, List<String> and
5// List<Integer> are both just List. The JVM cannot distinguish them,
6// so "list instanceof List<String>" is a COMPILE ERROR.
7class InstanceofMistake {
8 static void check(Object obj) {
9 if (obj instanceof List<String>) { // COMPILE ERROR in Java 16 and earlier
10 // Cannot check generic type at runtime - type argument is erased
11 }
12 }
13}
14
15// CORRECT - check only the raw type at runtime
16class InstanceofFixed {
17 static void check(Object obj) {
18 if (obj instanceof List<?> list) { // checks for List, not List<String>
19 System.out.println("Is a List with " + list.size() + " elements");
20 // Cannot safely confirm element type without further inspection
21 }
22 }
23}Interview Questions
Q1. What is a generic class in Java, and how is it declared?
A generic class is a class that declares one or more type parameters in angle brackets after the class name - class Box<T> or class Pair<K, V>. Those parameters act as placeholders for types the caller supplies when creating an instance - new Box<String>() binds T to String for that instance. Inside the class, T can be used anywhere a regular type would appear: as a field type, method parameter type, return type, or local variable type. The compiler tracks the binding of T at each usage site and verifies all operations on it are consistent with the declared type argument. At runtime, after type erasure, T becomes Object in the bytecode and all parameterized types of Box share the single Box.class object.
Q2. Why can't a generic class use its type parameter T in a static field or static method?
Static members belong to the class itself, shared across all instances of any parameterization. A static field T defaultValue in Box<T> would need to be String for Box<String> instances and Integer for Box<Integer> instances simultaneously — which is impossible since there is only one field. Type erasure makes this concrete: at runtime, there is only Box.class, not separate class objects for Box<String> and Box<Integer>, so there is only one copy of any static field. The compiler rejects T in static contexts to prevent this structural impossibility. Static methods can, however, declare their own independent type parameters, which is a different mechanism from using the class's type parameter.
Q3. What are the two ways a generic class can implement a generic interface?
The first pattern keeps the implementing class generic: class PrintableList<T> implements Printable<T> passes its own type parameter through to the interface. The implementing class remains generic and the caller decides T. PrintableList<String> is a Printable<String>, and PrintableList<Integer> is a Printable<Integer>. The second pattern fixes the type argument: class StringPrinter implements Printable<String> supplies String as the concrete type argument. The implementing class is no longer generic — it is always and only a Printable<String>. Both patterns appear throughout the JDK: ArrayList<E> uses the first pattern (implements List<E>), while Properties uses the second (extends Hashtable<Object,Object>).
Q4. Why is new T() not allowed inside a generic class, and what is the correct alternative?
new T() is a compile error because type erasure removes T before the code runs. At runtime, the JVM would need to call a constructor, but it has no record of which class T refers to — that information existed only in the compiler's type-checking phase. The standard alternatives are to accept a Supplier<T> as a constructor parameter (the caller passes StringBuilder::new or a lambda) or a Class<T> and use reflection via clazz.getDeclaredConstructor().newInstance(). Both approaches defer the "which concrete type" decision to the caller — where the information is actually known.
Q5. What is the difference between a generic type and a parameterized type, and why does it matter for instanceof checks?
A generic type is the declaration — class Box<T> — with the type parameter as a placeholder. A parameterized type is a specific instantiation — Box<String> — with a concrete type filling the placeholder. The distinction matters for instanceof because parameterized types do not exist as distinct entities at runtime: after erasure, Box<String> and Box<Integer> are both just Box. The JVM cannot check obj instanceof Box<String> because the <String> information is gone. The legal form is obj instanceof Box<?> (checking for the raw generic type) or obj instanceof Box (same, without the wildcard). Java 16+ pattern matching allows obj instanceof Box<?> box, but the element type remains unknown.
Q6. How does a generic class with a bounded type parameter differ from one with an unbounded parameter?
An unbounded type parameter <T> accepts any reference type — Box<String>, Box<Integer>, Box<Order>. The only methods the class can call on a value of type T are those declared on Object, because that is the only type guaranteed to be in scope. A bounded type parameter <T extends Comparable<T>> restricts the set of acceptable types to those that implement Comparable<T>. Inside the class, a value of type T can have compareTo() called on it, because the bound guarantees that method exists. Bounded type parameters let a generic class access a known set of methods on its type parameter without losing type safety or generality — covering any type that satisfies the bound, not just one specific type.
FAQs
Can a generic class extend another generic class?
Yes. There are two patterns: a generic subclass can pass its own type parameter to the superclass (class TypedList<T> extends AbstractList<T>), keeping the hierarchy generic. Or a concrete subclass can supply a fixed type argument (class StringList extends AbstractList<String>), making the subclass non-generic while the superclass remains generic. Both patterns appear in the JDK — ArrayList<E> extends AbstractList<E> (first pattern), while StringList would be an example of the second.
Can a generic class have more than two type parameters?
Yes — there is no language limit on the number of type parameters. However, three or more type parameters in a single class is a common code smell in application code. When a class needs three independent types, it often means it is doing too much (could be split) or that some type relationships could be expressed as bounded type parameters on fewer parameters. Standard library examples with more than two (Function, BiFunction) cap at two for this reason. Third-party utility types occasionally use three; more than three is nearly always a design issue.
Is a generic class slower than a non-generic equivalent?
No, not in any meaningful sense. Type erasure means the compiled bytecode is essentially identical — the same Object fields, the same method signatures with Object parameters. The CHECKCAST instructions the compiler inserts at read sites are the same bytecode a manual cast would produce. The JIT compiler applies the same optimizations to both. The one actual cost is boxing when type arguments are primitive wrapper types (Integer, Double) instead of primitives — but that cost comes from boxing, not from generics.
Does a generic class need to override equals() and hashCode() differently from a non-generic class?
No. equals() and hashCode() are inherited from Object (or overridden by the class as normal) and their implementation is independent of whether the class is generic. A Pair<String, Integer> can implement equals() using its first and second fields the same way a non-generic Pair would. The type parameters do not affect equals() or hashCode() directly. One subtlety: equals() typically uses instanceof to check the other object's type, and the correct form for a generic class is obj instanceof Pair<?, ?> (using unbounded wildcards) rather than trying to match the type argument, since the type argument is not available at runtime.
Can a generic class declare a generic inner class?
Yes, and the inner class can have its own type parameters independent of the outer class's. If the inner class is non-static (a member inner class), it implicitly has access to the outer class's type parameter. If it is a static nested class, it must declare its own parameters — it has no access to the enclosing class's type parameter, for the same reason static fields cannot use the enclosing class's type parameter.
Why does Java use <> (the diamond operator) on the right side of a declaration?
Before Java 7, every instantiation required repeating the type argument: Map<String, List<Integer>> map = new HashMap<String, List<Integer>>(). The diamond operator <> tells the compiler to infer the type argument from the left side of the declaration: Map<String, List<Integer>> map = new HashMap<>(). The compiler already has the complete type information from the declared type; repeating it on the right was purely redundant. The diamond was added in Java 7 as a quality-of-life improvement — it is purely syntactic sugar with no effect on runtime behavior or type safety.
Summary
A generic class declares one or more type parameters in angle brackets after the class name and uses them as placeholders throughout its body. The caller supplies the actual types at instantiation, and the compiler verifies every operation is consistent with those types. One class definition serves all parameterizations safely — the same Stack<T> handles Stack<String> and Stack<Integer> without duplication.
Three rules to carry forward. First, type parameters cannot appear in static members — static fields and methods belong to the class, not to any parameterization, and the same field cannot hold different types for different instantiations simultaneously. Second, new T(), T.class, and new T[n] are all compile errors — after type erasure, T is gone from the bytecode and none of these operations have the information they need to work. Third, instanceof cannot check parameterized types — obj instanceof Box<String> fails because the <String> is erased; obj instanceof Box<?> or obj instanceof Box is the correct runtime check.
Generic classes appear everywhere in production Java: repository patterns, result types, event buses, builder classes, caches, and every data structure in the Collections Framework. Recognizing the pattern — class name followed by angle brackets, type used throughout the body — and understanding what the compiler does with it is the foundation everything else in the Generics series builds on.
What to Read Next
Learn how to write an interface that works with any data type.