Skip to main content
Java advanced Lesson 26 of 58

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

FeaturePurpose
<T> class/interfaceReusable 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 methodsPer-method type inference, utility helpers
Class<T> tokenWork around type erasure for reflective creation

Frequently Asked Questions

What is type erasure?
Java implements generics through type erasure — the generic type information is removed at compile time and replaced with Object (or the bound type). At runtime, a List<String> and List<Integer> are both just List. This is why you cannot do new T() or check instanceof T at runtime.
What is the difference between List<? extends Animal> and List<? super Animal>?
extends (upper bound) means the list holds Animal or any subtype — you can read Animals out but cannot add to it safely. super (lower bound) means the list holds Animal or any supertype — you can add Animals to it but can only read Object out. The PECS rule: Producer Extends, Consumer Super.
Can I use generics with abstract classes and interfaces?
Yes. Generic type parameters work the same way on abstract classes and interfaces. Abstract methods can be generic, and subclasses can provide concrete type arguments or remain generic themselves.