Generics and OOP in Java
Learn how Java generics and OOP work together — generic classes, bounded type parameters, wildcards, generic methods, and type-safe collection design.
Generics let you write type-safe, reusable code without sacrificing flexibility. Without generics, a container class would have to store Object references, forcing callers to cast every time they read a value and offering no compile-time protection against putting the wrong type in. With generics, the compiler catches type mismatches before the code ever runs. Combined with OOP’s inheritance and polymorphism, generics are the foundation of Java’s entire collections framework and most production APIs.
Generic Classes
A generic class parameterises over one or more types, written as <T> after the class name. The type parameter acts as a placeholder that callers fill in when they create an instance. This lets a single class work correctly and safely for any type — no casts, no Object references leaking through the API.
public class Result<T> {
private final T value;
private final String error;
private final boolean success;
private Result(T value, String error, boolean success) {
this.value = value;
this.error = error;
this.success = success;
}
// Static factory methods carry their own <T> so the type is inferred from the argument
public static <T> Result<T> ok(T value) {
return new Result<>(value, null, true);
}
public static <T> Result<T> fail(String error) {
return new Result<>(null, error, false);
}
public boolean isSuccess() { return success; }
public T getValue() { if (!success) throw new NoSuchElementException(error); return value; }
public String getError() { return error; }
// map transforms the value if present, otherwise propagates the error
public <U> Result<U> map(java.util.function.Function<T, U> mapper) {
if (!success) return Result.fail(error);
return Result.ok(mapper.apply(value));
}
@Override
public String toString() {
return success ? "Result.ok(" + value + ")" : "Result.fail(" + error + ")";
}
}
// Works with any type — the compiler enforces correctness at each call site
Result<Integer> parsed = parseInteger("42");
Result<String> upper = parsed.map(n -> "Number is " + n);
System.out.println(upper); // Result.ok(Number is 42)
Result<Integer> failed = parseInteger("bad");
System.out.println(failed.map(n -> n * 2)); // Result.fail(Not a valid integer: bad)
private static Result<Integer> parseInteger(String s) {
try {
return Result.ok(Integer.parseInt(s));
} catch (NumberFormatException e) {
return Result.fail("Not a valid integer: " + s);
}
}
Bounded Type Parameters
Sometimes you need to restrict which types are accepted — for example, to call .doubleValue() on every element. Bounded type parameters (<T extends SomeType>) let you express this constraint. The bound can be a class or an interface, and you can combine multiple bounds with &. This lets you write generic code that still relies on specific API methods.
public class Statistics {
// T must extend Number — guarantees we can call .doubleValue() on every element
public static <T extends Number> double average(List<T> numbers) {
return numbers.stream()
.mapToDouble(Number::doubleValue)
.average()
.orElse(0.0);
}
// T must be both a Number and Comparable — needed for max() to work
public static <T extends Number & Comparable<T>> T max(List<T> values) {
return values.stream()
.max(Comparator.naturalOrder())
.orElseThrow(() -> new NoSuchElementException("Empty list"));
}
}
System.out.println(Statistics.average(List.of(1, 2, 3, 4, 5))); // 3.0
System.out.println(Statistics.average(List.of(1.5, 2.5, 3.0))); // 2.333...
System.out.println(Statistics.max(List.of(10, 3, 7, 1, 15, 4))); // 15
Generic Interfaces and Abstract Classes
Generics compose naturally with OOP. A generic interface defines a contract parameterised over types, and a generic abstract class can implement that interface while leaving the type concrete only at the leaf subclass. This pattern powers the repository pattern used across most Java frameworks — you write the persistence plumbing once in the abstract base, and every entity-specific repository just provides its identity extraction logic.
public interface Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
T save(T entity);
void delete(ID id);
long count();
}
// Abstract base provides common in-memory plumbing for any entity type
public abstract class InMemoryRepository<T, ID> implements Repository<T, ID> {
protected final Map<ID, T> storage = new LinkedHashMap<>();
// Subclass provides the key-extraction logic — it knows the entity shape
protected abstract ID extractId(T entity);
@Override
public Optional<T> findById(ID id) { return Optional.ofNullable(storage.get(id)); }
@Override
public List<T> findAll() { return List.copyOf(storage.values()); }
@Override
public T save(T entity) { storage.put(extractId(entity), entity); return entity; }
@Override
public void delete(ID id) { storage.remove(id); }
@Override
public long count() { return storage.size(); }
}
// Concrete repository: provides entity type, ID type, and ID extraction
public class UserRepository extends InMemoryRepository<User, Long> {
@Override
protected Long extractId(User user) { return user.getId(); }
// Domain-specific queries added on top of the generic base
public Optional<User> findByEmail(String email) {
return storage.values().stream()
.filter(u -> u.getEmail().equalsIgnoreCase(email))
.findFirst();
}
}
UserRepository repo = new UserRepository();
repo.save(new User(1L, "Alice", "alice@example.com"));
repo.save(new User(2L, "Bob", "bob@example.com"));
System.out.println(repo.count()); // 2
System.out.println(repo.findByEmail("alice@example.com")); // Optional[User(1, Alice)]
Wildcards — PECS Rule
Java’s type system does not consider List<Dog> to be a subtype of List<Animal>, even though Dog is a subtype of Animal. This is intentional — if it did, you could add a Cat to a List<Dog> through a List<Animal> reference. Wildcards are the solution: they express “some unknown subtype” or “some unknown supertype” so methods can accept a range of parameterised types safely.
PECS — Producer Extends, Consumer Super is the rule for remembering which wildcard to use.
public class AnimalShelter {
// PRODUCER: the list produces (provides) animals to be read — use ? extends
// You can read Animal values out, but you cannot add anything to the list
public static double totalWeight(List<? extends Animal> animals) {
return animals.stream().mapToDouble(Animal::getWeight).sum();
}
// CONSUMER: the list consumes (accepts) dogs being added — use ? super
// You can add Dogs to it, but you can only read Object back out
public static void addDogs(List<? super Dog> destination, int count) {
for (int i = 0; i < count; i++) {
destination.add(new Dog("Dog #" + i, "Mixed"));
}
}
// Both reads AND writes with a known type — use a concrete type, no wildcard
public static void swap(List<Animal> list, int i, int j) {
Animal temp = list.get(i);
list.set(i, list.get(j));
list.set(j, temp);
}
}
List<Dog> dogs = new ArrayList<>(List.of(new Dog("Rex", "Lab"), new Dog("Buddy", "Poodle")));
List<Animal> animals = new ArrayList<>(List.of(new Cat("Whiskers"), new Dog("Max", "Husky")));
System.out.println(AnimalShelter.totalWeight(dogs)); // works: Dog extends Animal
System.out.println(AnimalShelter.totalWeight(animals)); // works: Animal extends Animal
AnimalShelter.addDogs(dogs, 2); // works: List<Dog> is a List<? super Dog>
AnimalShelter.addDogs(animals, 1); // works: List<Animal> is a List<? super Dog>
Generic Methods
A method can introduce its own type parameter independently of the class it belongs to. The type is inferred by the compiler from the arguments at the call site — you rarely need to specify it explicitly. Generic utility methods are the cleanest way to write helpers that work across many types without code duplication.
public class CollectionUtils {
// T inferred from the argument — caller writes CollectionUtils.repeat("x", 3) with no type hint
public static <T> List<T> repeat(T element, int times) {
List<T> result = new ArrayList<>(times);
for (int i = 0; i < times; i++) result.add(element);
return result;
}
public static <T> Optional<T> firstMatching(List<T> list, java.util.function.Predicate<T> predicate) {
return list.stream().filter(predicate).findFirst();
}
// Multiple type params: K and V are inferred independently
public static <K, V> Map<V, K> invertMap(Map<K, V> original) {
Map<V, K> inverted = new HashMap<>();
original.forEach((k, v) -> inverted.put(v, k));
return inverted;
}
}
List<String> hellos = CollectionUtils.repeat("Hello", 3);
System.out.println(hellos); // [Hello, Hello, Hello]
Optional<Integer> first = CollectionUtils.firstMatching(
List.of(1, 5, 3, 8, 2), n -> n > 4);
System.out.println(first); // Optional[5]
Map<String, Integer> scores = Map.of("Alice", 95, "Bob", 87);
Map<Integer, String> byScore = CollectionUtils.invertMap(scores);
System.out.println(byScore); // {95=Alice, 87=Bob}
Type Erasure — What You Cannot Do
Java implements generics through type erasure: the compiler removes all generic type information after it checks types at compile time. At runtime, List<String> and List<Integer> are both just List. This means certain operations that would require knowing T at runtime are impossible — you cannot create instances of T, create arrays of T, or use instanceof T. The standard workaround is to pass a Class<T> token so your code can perform reflection-based operations.
public class GenericLimitations<T> {
// Cannot do — T is erased to Object at runtime
// T instance = new T(); // COMPILE ERROR
// T[] array = new T[10]; // COMPILE ERROR
// if (obj instanceof T) { ... } // COMPILE ERROR
// Workaround: accept the Class<T> token explicitly
private final Class<T> type;
public GenericLimitations(Class<T> type) { this.type = type; }
public T createInstance() throws Exception {
return type.getDeclaredConstructor().newInstance(); // uses reflection
}
public boolean isInstance(Object obj) {
return type.isInstance(obj); // safe runtime type check via the token
}
}
GenericLimitations<StringBuilder> g = new GenericLimitations<>(StringBuilder.class);
StringBuilder sb = g.createInstance(); // works via reflection
System.out.println(g.isInstance(sb)); // true
Summary
| Feature | Purpose |
|---|---|
<T> class/interface | Reusable type-safe containers and APIs |
<T extends Bound> | Restrict what types are accepted |
<? extends T> | Read-only covariant collections (Producer) |
<? super T> | Write-only contravariant collections (Consumer) |
| Generic methods | Per-method type inference, utility helpers |
Class<T> token | Work around type erasure for reflective creation |