Skip to main content
Java · lesson 26 of 66
Open the Java page →
Java advanced Lesson 26 of 66

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.

By SkillByExample · Updated

Generics let a class or interface be written once and used safely with many types; OOP lets those generic types form hierarchies. Together they give you type-safe containers, repositories and result types with no casts. Each example is a single file run with the JDK 21 source launcher (java File.java).

Why generics: the Object-based box

Before generics, a reusable container stored Object, and every read needed a cast that could fail at runtime.

public class RawBox {
    public static void main(String[] args) {
        ObjectBox box = new ObjectBox();
        box.set("hello");
        String s = (String) box.get();
        System.out.println(s.toUpperCase());

        box.set(42);                        // nothing stops this
        String t = (String) box.get();
        System.out.println(t);
    }
}

class ObjectBox {
    private Object value;
    void set(Object v) { value = v; }
    Object get()       { return value; }
}
$ java RawBox.java
HELLO
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at RawBox.main(RawBox.java:9)

The mistake is on line 8 but the crash is on line 9 — or, in a real program, much further away. A generic Box<T> moves the check to compile time:

public class GenericBox {
    public static void main(String[] args) {
        Box<String> box = new Box<>();
        box.set("hello");
        System.out.println(box.get().toUpperCase());   // no cast
        box.set(42);
    }
}

class Box<T> {
    private T value;
    void set(T v) { value = v; }
    T get()       { return value; }
}
$ java GenericBox.java
GenericBox.java:6: error: method set in class Box<T> cannot be applied to given types;
        box.set(42);
           ^
  required: String
  found:    int
  reason: argument mismatch; int cannot be converted to String
  where T is a type-variable:
    T extends Object declared in class Box
1 error
error: compilation failed

A generic result type

Result<T> holds either a value of type T or an error. Static factory methods declare their own <T>, and map introduces a second type U for the transformed value.

import java.util.function.Function;

public class ResultDemo {
    public static void main(String[] args) {
        Result<Integer> ok = parse("42");
        Result<String> described = ok.map(n -> "number " + n);
        System.out.println(described);
        System.out.println(parse("bad").map(n -> n * 2));
    }

    static Result<Integer> parse(String s) {
        try { return Result.ok(Integer.parseInt(s)); }
        catch (NumberFormatException e) { return Result.fail("not an integer: " + s); }
    }
}

final class Result<T> {
    private final T value; private final String error;
    private Result(T value, String error) { this.value = value; this.error = error; }

    static <T> Result<T> ok(T value)       { return new Result<>(value, null); }
    static <T> Result<T> fail(String error) { return new Result<>(null, error); }

    <U> Result<U> map(Function<? super T, ? extends U> f) {
        return error == null ? ok(f.apply(value)) : fail(error);
    }
    public String toString() { return error == null ? "ok(" + value + ")" : "fail(" + error + ")"; }
}
$ java ResultDemo.java
ok(number 42)
fail(not an integer: bad)

Result<Integer> became Result<String> through map with no casts. On the failure path the lambda never runs, so n * 2 is never evaluated on a missing value. The ? super T/? extends U wildcards on map let callers pass more general functions, the same way java.util.Optional.map does.

Generic interface, abstract base, concrete subclass

A generic interface defines a contract; a generic abstract class implements the shared plumbing; a subclass fixes the type arguments and adds domain methods.

import java.util.*;

public class RepositoryDemo {
    public static void main(String[] args) {
        UserRepository repo = new UserRepository();
        repo.save(new User(1L, "Alice", "alice@example.com"));
        repo.save(new User(2L, "Bob", "bob@example.com"));
        repo.save(new User(1L, "Alice", "alice@new.example"));   // same id: replaces
        System.out.println(repo.count() + " users");
        System.out.println(repo.findById(1L));
        System.out.println(repo.findByEmail("BOB@example.com"));
        System.out.println(repo.findById(9L));
    }
}

record User(Long id, String name, String email) {}

interface Repository<T, ID> {
    Optional<T> findById(ID id);
    T save(T entity);
    int count();
}

abstract class InMemoryRepository<T, ID> implements Repository<T, ID> {
    protected final Map<ID, T> storage = new LinkedHashMap<>();
    protected abstract ID idOf(T entity);

    public Optional<T> findById(ID id) { return Optional.ofNullable(storage.get(id)); }
    public T save(T entity)            { storage.put(idOf(entity), entity); return entity; }
    public int count()                 { return storage.size(); }
}

class UserRepository extends InMemoryRepository<User, Long> {
    protected Long idOf(User u) { return u.id(); }
    Optional<User> findByEmail(String email) {
        return storage.values().stream().filter(u -> u.email().equalsIgnoreCase(email)).findFirst();
    }
}
$ java RepositoryDemo.java
2 users
Optional[User[id=1, name=Alice, email=alice@new.example]]
Optional[User[id=2, name=Bob, email=bob@example.com]]
Optional.empty

InMemoryRepository never mentions User; UserRepository supplies <User, Long> and the one method that knows how to get an id. Inside UserRepository, storage is a Map<Long, User>, so u.email() needs no cast.

Implementing Comparable

A class that passes itself as the type argument to Comparable gets a type-safe compareTo, and every sorted collection can then use it.

import java.util.*;

public class Versions {
    public static void main(String[] args) {
        List<Version> vs = new ArrayList<>(List.of(Version.parse("1.10.0"), Version.parse("1.2.3"), Version.parse("1.2.10")));
        Collections.sort(vs);
        System.out.println(vs);
        System.out.println(new TreeSet<>(vs).last());

        List<String> asText = new ArrayList<>(List.of("1.10.0", "1.2.3", "1.2.10"));
        Collections.sort(asText);
        System.out.println(asText);
    }
}

record Version(int major, int minor, int patch) implements Comparable<Version> {
    static Version parse(String s) {
        String[] p = s.split("\\.");
        return new Version(Integer.parseInt(p[0]), Integer.parseInt(p[1]), Integer.parseInt(p[2]));
    }
    public int compareTo(Version o) {
        return Comparator.comparingInt(Version::major).thenComparingInt(Version::minor)
                         .thenComparingInt(Version::patch).compare(this, o);
    }
    public String toString() { return major + "." + minor + "." + patch; }
}
$ java Versions.java
[1.2.3, 1.2.10, 1.10.0]
1.10.0
[1.10.0, 1.2.10, 1.2.3]

Sorted as Versions, 1.2.10 comes after 1.2.3; sorted as strings, "1.10.0" comes first because '1' < '2' character by character. compareTo(Version o) takes a Version, not an Object, because of the <Version> type argument.

Wildcards across a class hierarchy

List<Dog> is not a List<Animal>. Wildcards let one method accept lists of related types: ? extends to read (a producer), ? super to write (a consumer).

import java.util.*;

public class Shelter {
    static double totalWeight(List<? extends Animal> animals) {
        double total = 0;
        for (Animal a : animals) total += a.weight();
        return total;
    }

    static void addPuppies(List<? super Dog> dest, int n) {
        for (int i = 1; i <= n; i++) dest.add(new Dog("Puppy" + i, 2.0));
    }

    public static void main(String[] args) {
        List<Dog> dogs = new ArrayList<>(List.of(new Dog("Rex", 30), new Dog("Buddy", 12)));
        List<Animal> animals = new ArrayList<>(List.of(new Cat("Whiskers", 4)));
        addPuppies(dogs, 2);
        addPuppies(animals, 1);
        System.out.println(totalWeight(dogs) + " kg of dogs, " + dogs.size() + " dogs");
        System.out.println(totalWeight(animals) + " kg of animals: " + animals);
    }
}

abstract class Animal {
    private final String name; private final double weight;
    Animal(String name, double weight) { this.name = name; this.weight = weight; }
    double weight() { return weight; }
    public String toString() { return getClass().getSimpleName() + " " + name; }
}
class Dog extends Animal { Dog(String n, double w) { super(n, w); } }
class Cat extends Animal { Cat(String n, double w) { super(n, w); } }
$ java Shelter.java
46.0 kg of dogs, 4 dogs
6.0 kg of animals: [Cat Whiskers, Dog Puppy1]

totalWeight accepts both lists because each holds “some subtype of Animal”; addPuppies accepts both because each can hold a Dog. Declared as List<Animal>, totalWeight(dogs) would not compile.

Working around erasure with a Class token

Type arguments are erased at runtime, so new T() and instanceof T are impossible. Passing a Class<T> object gives the code a real runtime type to work with.

import java.util.*;

public class TypedBag {
    public static void main(String[] args) throws Exception {
        Bag<StringBuilder> bag = new Bag<>(StringBuilder.class);
        StringBuilder sb = bag.create();
        sb.append("made by reflection");
        System.out.println(sb);

        List<Object> mixed = List.of("a", 1, new StringBuilder("b"), 2.5, new StringBuilder("c"));
        System.out.println(bag.filter(mixed));
    }
}

class Bag<T> {
    private final Class<T> type;
    Bag(Class<T> type) { this.type = type; }

    T create() throws ReflectiveOperationException {
        return type.getDeclaredConstructor().newInstance();
    }

    List<T> filter(List<?> items) {
        List<T> out = new ArrayList<>();
        for (Object o : items) if (type.isInstance(o)) out.add(type.cast(o));
        return out;
    }
}
$ java TypedBag.java
made by reflection
[b, c]

type.isInstance and type.cast do at runtime what instanceof T and (T) cannot. The token is an ordinary object, so erasure doesn’t touch it.

Summary

FeaturePurpose
class Box<T>reusable, type-safe container
static <T> Result<T> ok(T)factory that infers its type
generic interface + abstract baseshared plumbing, concrete types at the leaf
implements Comparable<Self>natural ordering for sorting and sorted collections
? extends / ? superaccept related types for reading / writing
Class<T> tokenruntime type for creation and checks

Practice

  1. Make Result.map catch an exception thrown by the mapper and return a failure carrying its message. Test with n -> n / 0.
Solution
import java.util.function.Function;

public class SafeMap {
    public static void main(String[] args) {
        System.out.println(Result.ok(10).map(n -> n / 2));
        System.out.println(Result.ok(10).map(n -> n / 0));
    }
}

final class Result<T> {
    private final T value; private final String error;
    private Result(T value, String error) { this.value = value; this.error = error; }
    static <T> Result<T> ok(T value)        { return new Result<>(value, null); }
    static <T> Result<T> fail(String error) { return new Result<>(null, error); }

    <U> Result<U> map(Function<? super T, ? extends U> f) {
        if (error != null) return fail(error);
        try { return ok(f.apply(value)); }
        catch (RuntimeException e) { return fail(e.getClass().getSimpleName() + ": " + e.getMessage()); }
    }
    public String toString() { return error == null ? "ok(" + value + ")" : "fail(" + error + ")"; }
}
$ java SafeMap.java
ok(5)
fail(ArithmeticException: / by zero)

The exception becomes data, so a chain of map calls never throws.

  1. Add a ProductRepository extends InMemoryRepository<Product, String> keyed by SKU, with a findCheaperThan(double) method.
Solution
import java.util.*;

public class Products {
    public static void main(String[] args) {
        ProductRepository repo = new ProductRepository();
        repo.save(new Product("A-1", 9.99));
        repo.save(new Product("B-2", 24.50));
        repo.save(new Product("C-3", 4.25));
        System.out.println(repo.findCheaperThan(10));
        System.out.println(repo.findById("B-2"));
    }
}

record Product(String sku, double price) {}

abstract class InMemoryRepository<T, ID> {
    protected final Map<ID, T> storage = new LinkedHashMap<>();
    protected abstract ID idOf(T entity);
    Optional<T> findById(ID id) { return Optional.ofNullable(storage.get(id)); }
    T save(T entity)            { storage.put(idOf(entity), entity); return entity; }
}

class ProductRepository extends InMemoryRepository<Product, String> {
    protected String idOf(Product p) { return p.sku(); }
    List<Product> findCheaperThan(double max) {
        return storage.values().stream().filter(p -> p.price() < max).toList();
    }
}
$ java Products.java
[Product[sku=A-1, price=9.99], Product[sku=C-3, price=4.25]]
Optional[Product[sku=B-2, price=24.5]]

The base class was reused unchanged with a different entity and a String key.

  1. Change totalWeight’s parameter to List<Animal> and call it with List<Dog>. What does the compiler say?
Solution
import java.util.List;

public class NoWildcard {
    static double totalWeight(List<Animal> animals) {
        return animals.stream().mapToDouble(Animal::weight).sum();
    }

    public static void main(String[] args) {
        List<Dog> dogs = List.of(new Dog(30), new Dog(12));
        System.out.println(totalWeight(dogs));
    }
}

class Animal { private final double w; Animal(double w) { this.w = w; } double weight() { return w; } }
class Dog extends Animal { Dog(double w) { super(w); } }
$ java NoWildcard.java
NoWildcard.java:10: error: method totalWeight in class NoWildcard cannot be applied to given types;
        System.out.println(totalWeight(dogs));
                           ^
  required: List<Animal>
  found:    List<Dog>
  reason: argument mismatch; List<Dog> cannot be converted to List<Animal>
1 error
error: compilation failed

Generics are invariant; List<? extends Animal> is what expresses “a list of some kind of animal”.

  1. Write invertMulti(Map<K, V>) returning Map<V, List<K>> so keys that share a value are all kept. Test with {a=1, b=2, c=1}.
Solution
import java.util.*;

public class InvertMulti {
    static <K, V> Map<V, List<K>> invertMulti(Map<K, V> original) {
        Map<V, List<K>> out = new TreeMap<>();
        original.forEach((k, v) -> out.computeIfAbsent(v, x -> new ArrayList<>()).add(k));
        return out;
    }

    public static void main(String[] args) {
        Map<String, Integer> m = new LinkedHashMap<>();
        m.put("a", 1); m.put("b", 2); m.put("c", 1);
        System.out.println(invertMulti(m));
    }
}
$ java InvertMulti.java
{1=[a, c], 2=[b]}

A plain Map<V, K> would keep only one of a and c; collecting into lists keeps both.

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.
What does class Foo implements Comparable<Foo> mean?
It says Foo objects can be compared with other Foo objects, so compareTo takes a Foo parameter rather than an Object. Sorting methods such as Collections.sort and TreeSet rely on that type argument, which is why a class almost always passes itself as the argument to Comparable.