Generics

Generics & Inheritance

15 min Lesson 9 of 13

Generics & Inheritance

One of the most common sources of confusion when learning generics is what happens when you combine them with Java's normal class hierarchy. You already know that String extends Object. You might therefore assume that a List<String> is a subtype of List<Object>. It is not — and this lesson explains exactly why, and what the real rules are.

The Core Surprise: Invariance

In Java, generic types are invariant. That means even though String is a subtype of Object, List<String> is not a subtype of List<Object>. They are completely unrelated types as far as the compiler is concerned.

The following code does not compile:

List<String> strings = new ArrayList<>(); List<Object> objects = strings; // COMPILE ERROR — incompatible types
Key idea — invariance: List<String> and List<Object> are siblings, not parent and child. Neither is a subtype of the other. This is the fundamental rule of Java generics combined with inheritance.

Why Invariance Is the Right Default

The restriction exists to preserve type safety. Suppose the compiler did allow that assignment. Then consider what could happen next:

List<String> strings = new ArrayList<>(); strings.add("hello"); // Pretend this were legal (it is NOT): List<Object> objects = strings; objects.add(42); // 42 is an Object, looks fine to the compiler String s = strings.get(1); // ClassCastException at runtime!

Through the objects reference you inserted an Integer into what is physically a List<String>. Reading it back as a String crashes at runtime. Invariance stops this entire chain of problems at compile time, before the program ever runs.

Java arrays are a useful contrast here: arrays are covariant, meaning String[] is a subtype of Object[]. That flexibility comes at a cost — the JVM must perform a runtime type check on every array write, and it throws ArrayStoreException when the check fails. Generics chose compile-time safety over runtime patching.

When you need flexibility, use wildcards. List<? extends Object> (or simply List<?>) accepts any parameterised list as a read-only source. That is covered in the Wildcards lessons; here we are focused on understanding why the base rule exists.

What Does Have a Subtype Relationship?

Two things are safe and work as expected:

  • Same type argument, different container class: Because ArrayList implements List, an ArrayList<String> is a subtype of List<String>. Both sides use the same type argument, so no unsafety arises.
  • Raw types: A raw List (no type argument) is a supertype of List<String>. This compiles, but you lose type safety and get unchecked-cast warnings — avoid raw types in new code.
// Fine — same type argument, subclass container List<String> list = new ArrayList<String>(); // Fine — LinkedList also implements List List<Integer> linked = new LinkedList<Integer>(); // Avoid — raw type, loses type safety List raw = new ArrayList<String>(); // compiles with warning

Generic Classes Can Still Extend Other Generic Classes

You can extend or implement a generic type just like any other type. The invariance rule only prevents treating one parameterisation as another parameterisation of the same generic. When you extend with a matching or bound-compatible type parameter, everything is fine:

// Forward the same type parameter to the parent class NumberList<T extends Number> extends ArrayList<T> { public double sum() { double total = 0; for (T item : this) { total += item.doubleValue(); } return total; } } // Usage NumberList<Integer> ints = new NumberList<>(); ints.add(10); ints.add(20); ints.add(30); System.out.println(ints.sum()); // 60.0 // NumberList<Integer> IS-A List<Integer> (same argument, subclass container — fine) List<Integer> view = ints; // NumberList<Integer> is NOT a List<Number> or List<Object> — invariance still applies

Implementing a Generic Interface with a Fixed Type Argument

A concrete class can implement a generic interface and fix the type argument to a specific type. The concrete class is then a subtype of that specific parameterisation:

interface Repository<T> { void save(T entity); T findById(int id); } class UserRepository implements Repository<String> { private final java.util.Map<Integer, String> store = new java.util.HashMap<>(); @Override public void save(String entity) { store.put(store.size(), entity); } @Override public String findById(int id) { return store.get(id); } } // UserRepository IS-A Repository<String> — legal assignment Repository<String> repo = new UserRepository(); repo.save("Alice"); System.out.println(repo.findById(0)); // Alice

Putting It All Together: The Mental Model

When you see a generic type like Container<T>, think of the angle-bracket part as a brand that is fixed at compile time. Two containers with different brands are entirely different types regardless of any subtype relationship between the brands themselves. The only exception is when the container class itself is in a subtype relationship (e.g. ArrayList extends AbstractList implements List) and both sides carry the same brand.

Common mistake: Writing a method that accepts List<Object> and then being surprised that you cannot pass a List<String> to it. The fix is to use an upper-bounded wildcard: List<? extends Object> — covered in the next lessons.

Summary

  • Generic types in Java are invariant: List<String> is not a subtype of List<Object>.
  • Invariance is intentional — it prevents a category of runtime ClassCastException that covariant arrays can cause.
  • Normal inheritance does apply when the type argument is the same: ArrayList<String> is a subtype of List<String>.
  • Generic classes can extend or implement other generic types by forwarding or bounding the type parameter.
  • When you need read-only flexibility across parameterisations, use wildcards — the subject of the next two lessons.