Hello. In the previous lesson, you made ProductCode reliable as a hash-map key by defining stable equality and hashing. That gives us a useful backend-oriented starting point: a repository can safely look up a product using a newly constructed but logically equivalent ProductCode.
Now we will make the repository API itself harder to misuse. Generics let an API state relationships such as “this repository accepts this identifier type and returns this entity type,” and Java checks those relationships at compile time. By the end, you should be able to design a small generic API, add a meaningful bound when the implementation needs a capability, and explain why wildcards are needed for flexible collection parameters.
Generics express a contract between callers and an API
Before generics, a reusable container or service often accepted Object:
public final class UnsafeStore {
private Object value;
public void put(Object value) {
this.value = value;
}
public Object get() {
return value;
}
}
This is flexible, but it gives up an important guarantee. A caller must cast on retrieval:
UnsafeStore store = new UnsafeStore();
store.put("not a product");
Product product = (Product) store.get(); // ClassCastException at runtime
The error appears only when that path executes. Tests may miss it, and the exception occurs away from the earlier incorrect insertion.
A generic type turns the element type into part of the API’s contract:
public final class Store<T> {
private T value;
public void put(T value) {
this.value = value;
}
public T get() {
return value;
}
}
At each use site, the caller supplies a type argument:
Store<Product> products = new Store<>();
products.put(new Product(new ProductCode("SKU-42"), "Mouse"));
// products.put("not a product"); // Does not compile.
Product product = products.get(); // No cast required.
T is a type parameter: a placeholder in the declaration of Store. Product is a type argument: the actual type chosen for one particular Store<Product> instance.
The compiler conceptually substitutes the chosen type argument throughout the API. For Store<Product>, put(T) behaves as put(Product) and get() behaves as Product get().
Watch the following short segment for a visual walkthrough of this shift from duplicated or Object-based code to a generic class.
Generics In Java - Full Simple Tutorial
“Generics In Java - Full Simple Tutorial” by Coding with John demonstrates both the reusable generic-class idea and the compile-time safety that follows from it.
Watch the generic refactor to see a type parameter replace repeated type-specific classes. Then watch collection safety, focusing on the contrast between retrieving Object with a cast and retrieving a known generic type without one.
A generic API provides three practical benefits:
- Misuse becomes a compiler error. A
Store<Product>cannot receive aString. - Callers avoid unchecked casts. The returned value preserves its declared type.
- One implementation works for many types. You do not need
ProductStore,CustomerStore, andOrderStoremerely because the stored type changes.
This is compile-time type safety, not a replacement for every kind of validation. Generics cannot prove that a JSON payload has valid business fields, that a database contains consistent data, or that a value is non-null. They express and enforce type relationships within Java code.
Design a small type-safe repository
A generic API should capture a real relationship rather than make every class generic by default. A repository is a good example: it works with an entity type and the type of that entity’s identifier.
First, define the capability the repository needs from an entity:
public interface Identified<ID> {
ID id();
}
Then define a product that uses the ProductCode value object from the previous lesson:
public record Product(ProductCode id, String name)
implements Identified<ProductCode> {
}
Now the repository can use two type parameters:
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public final class InMemoryRepository<ID, T extends Identified<ID>> {
private final Map<ID, T> entities = new HashMap<>();
public void save(T entity) {
Objects.requireNonNull(entity, "entity");
entities.put(entity.id(), entity);
}
public Optional<T> findById(ID id) {
Objects.requireNonNull(id, "id");
return Optional.ofNullable(entities.get(id));
}
}
The declaration contains the design:
InMemoryRepository<ID, T extends Identified<ID>>
Read it in pieces:
IDis the identifier type.Tis the entity type.T extends Identified<ID>is a bounded type parameter. It says thatTmust implementIdentified<ID>.- Because of that bound, Java knows that
entity.id()exists and returns exactlyID.
The bound does more than say “this entity has some ID.” It preserves the relationship between the two parameters. A repository keyed by ProductCode can only save entities whose id() returns ProductCode.
InMemoryRepository<ProductCode, Product> products =
new InMemoryRepository<>();
products.save(new Product(
new ProductCode("sku-42"),
"Wireless Mouse"
));
Optional<Product> found =
products.findById(new ProductCode("SKU-42"));
The lookup succeeds because the prior ProductCode implementation normalizes its value and supplies compatible equals and hashCode methods. Generics ensure that the caller presents a ProductCode; equality and hashing ensure that equivalent codes locate the same map entry.
The following mistakes are rejected before the application runs:
// products.findById(UUID.randomUUID()); // Wrong identifier type.
// products.save(new Customer(...)); // Wrong entity type.
Without generics, a repository might expose save(Object) and findById(Object). Its implementation would need casts, or it would defer incorrect combinations until runtime. With generics, the repository tells its caller what constitutes a valid interaction.
Bounds should follow a capability you actually need
If the repository did not call entity.id(), this bound would be unnecessary:
public final class Box<T> {
private final T value;
public Box(T value) {
this.value = value;
}
public T value() {
return value;
}
}
Use an unbounded T when any reference type is valid. Add extends SomeType only when the implementation needs behavior guaranteed by SomeType.
For example, a generic algorithm that compares values needs the comparison capability:
public static <T extends Comparable<T>> T largerOf(T first, T second) {
return first.compareTo(second) >= 0 ? first : second;
}
The type parameter belongs before the method return type:
public static <T extends Comparable<T>> T largerOf(...)
This is a generic method. Its T exists only for that method invocation, unlike T on InMemoryRepository, which applies to the whole class instance.
A common interview detail: Java uses extends in bounds even when the bound is an interface. For example, T extends Comparable<T> is correct, not T implements Comparable<T>.
For concise reference reading on raw types, bounded parameters, and generic invariance, use the official Dev.java tutorial.
Introducing Generics - Dev.java
Read the selected parts of Dev.java’s “Introducing Generics” to connect the repository design to Java’s formal generic-type rules.
In the “Raw Types” section, read from the raw-type definition through the warning discussion; focus on why raw types reintroduce runtime risk. In “Bounded Type Parameters,” read the purpose of bounds. Finally, in “Generics, Inheritance, and Subtypes,” read the invariance example, especially the fact that Box<Integer> is not a subtype of Box<Number>.
Do not hide warnings with raw types
A raw type omits type arguments:
InMemoryRepository repository = new InMemoryRepository();
Raw types remain primarily for compatibility with code written before Java generics. They weaken the compiler checks that generics were introduced to provide. If legacy integration forces an unchecked cast, isolate it at a small boundary, inspect the incoming values, and document why it is safe. Do not spread raw types through ordinary application code or suppress warnings merely to make a build quiet.
Also, generic type arguments must be reference types. Use Integer, not int; Long, not long; and Boolean, not boolean. Java’s boxing and unboxing make common uses such as List<Integer> ergonomic.
Why List<Integer> is not a List<Number>
A frequent interview trap is this intuition:
An
Integeris aNumber, so aList<Integer>should be aList<Number>.
The first claim is true. The second is false.
List<Integer> integers = List.of(1, 2, 3);
// List<Number> numbers = integers; // Does not compile.
Java makes parameterized types invariant: List<Integer> and List<Number> are different, unrelated types.
That restriction protects the list. If the assignment above were legal, ordinary code using the List<Number> reference could add a Double:
void addPi(List<Number> numbers) {
numbers.add(3.14);
}
But the actual list would be a List<Integer>, which must never contain a Double. Invariance prevents this contradiction at compile time.

Wildcards create carefully controlled flexibility when an API does not need one exact element type.
Choose wildcards by what the method does
The most useful rule is PECS:
Producer Extends, Consumer Super.
A producer supplies values to your method, so use extends. A consumer accepts values from your method, so use super.
Read from a producer: ? extends T
Suppose a method only needs to read numeric values:
public static double sum(List<? extends Number> values) {
double total = 0.0;
for (Number value : values) {
total += value.doubleValue();
}
return total;
}
This accepts lists whose elements are Number or a subtype:
double wholeNumbers = sum(List.of(1, 2, 3));
double decimals = sum(List.of(1.5, 2.5));
Inside the method, each retrieved element is safely a Number. But you cannot safely add a new Number:
void cannotAdd(List<? extends Number> values) {
// values.add(3.14); // Does not compile.
}
The actual argument might be List<Integer>, so adding a Double would be unsafe. Informally, treat List<? extends Number> as a read-oriented view. Technically, it is not completely immutable: operations such as clear() may still be permitted, and null may be added. The important rule is that Java cannot accept a useful newly created Number value into it.
Write to a consumer: ? super T
Now suppose a method needs to add Integer values:
public static void addDefaults(List<? super Integer> destination) {
destination.add(1);
destination.add(2);
destination.add(3);
}
The destination may be a List<Integer>, List<Number>, or List<Object>:
List<Number> numbers = new ArrayList<>();
addDefaults(numbers);
List<Object> values = new ArrayList<>();
addDefaults(values);
All of those lists can hold an Integer. In contrast, reading from List<? super Integer> gives only an Object guarantee, because the actual list may be a list of Integer, Number, or Object.
Connect a producer and consumer with a generic method
The power of generics appears when one method connects both sides while preserving the relationship:
public static <T> void copyAll(
List<? extends T> source,
List<? super T> destination
) {
for (T item : source) {
destination.add(item);
}
}
A List<Integer> can supply values, while a List<Number> can receive them:
List<Integer> source = List.of(10, 20);
List<Number> destination = new ArrayList<>();
copyAll(source, destination);
The compiler infers a suitable T and proves each copied value is accepted by the destination. This is safer and more reusable than writing one overload for every numeric subtype.
Use ? when the element type is irrelevant
An unbounded wildcard, List<?>, means “a list of some unknown type.” Use it if the method only needs operations available regardless of that type:
public static void logSize(List<?> items) {
System.out.println("Items: " + items.size());
}
logSize can accept List<Product>, List<String>, or List<Integer>. Its implementation does not need to know the element type.
Do not write List<Object> for this purpose. List<Object> means callers must provide a list that can hold arbitrary objects. It does not accept List<Product>.
Read the official wildcard guidance with the following questions in mind: What can be read safely, what can be written safely, and why does invariance require this distinction?
Dev.java’s “Wildcards” explains upper bounds, lower bounds, and the formal version of the producer-consumer reasoning used in this lesson.
In “Upper Bounded Wildcards,” read the upper-bound explanation; connect it to reading Number values from List<? extends Number>. In “Unbounded Wildcards,” focus on why List<?>, not List<Object>, accepts lists with arbitrary element types. Then read “Lower Bounded Wildcards” from the lower-bound definition. In “Wildcards and Subtyping,” read the invariance discussion. Finish with the decision rules in “Guidelines for Wildcard Use,” beginning the in-out guidelines.
Avoid wildcard return types in ordinary API design:
// Awkward for callers:
public List<? extends Product> findProducts() { ... }
A caller cannot conveniently treat elements as one precise subtype, so the wildcard leaks an implementation concern into the API. Prefer a concrete return type such as List<Product>, or use a method-level type parameter when the caller genuinely needs a generic result.
Explain it in an interview
For “How do generics make an API type-safe?”, lead with the relationship rather than a textbook definition:
Generics let an API declare type relationships that the compiler checks at every call site. For example, I might define an
InMemoryRepository<ID, T extends Identified<ID>>. That means the repository accepts only entities of typeT, looks them up only with the matchingIDtype, and can safely callid()because the bound guarantees that capability. AInMemoryRepository<ProductCode, Product>therefore rejects both aUUIDlookup and an attempt to save an unrelated entity at compile time. This removes casts and shifts manyClassCastException-style failures from runtime to compilation. The trade-off is that I need wildcards when I want flexible collection inputs, becauseList<Integer>is deliberately not aList<Number>.
For the follow-up, “When do you use extends versus super?”, make the safety argument explicit:
I use
? extends Twhen the parameter producesTvalues for my method to read, and? super Twhen my method suppliesTvalues to it. WithList<? extends Number>, I can read each value as aNumber, but I cannot safely add aDoublebecause the actual list may be aList<Integer>. WithList<? super Integer>, I can add integers, but when reading I only know that I have anObject.
Key takeaways
- Generics make type relationships part of a Java API’s compile-time contract.
- A generic class binds its type parameters for the life of an instance; a generic method declares type parameters for one invocation.
- Use a bounded type parameter such as
T extends Identified<ID>when the implementation requires a specific capability. - Avoid raw types in new code because they bypass generic checking and invite unchecked casts.
- Generic types are invariant:
List<Integer>is not aList<Number>. - Use
? extends Tfor a value producer you need to read from, and? super Tfor a consumer you need to writeTvalues to. - Use
List<?>when the element type is irrelevant; do not confuse it withList<Object>. - Generics complement, rather than replace, runtime validation and domain invariants.
Next, you will transform collections with lambdas and streams, while learning when a straightforward loop—such as the copyAll loop here—communicates the code’s intent more clearly.
Can't find a good explanation? Sign up and we'll make it for you
Sign up