(DRAFT) Java Wildcards: Taming Generic Invariance

Draft – first Draft. Wanted to get this out sooner to help with study. Please note, this article was generated using AI with me in the loop.

In this post, we are going to look at wildcards in Java. Wildcards are a feature of Java generics that let you write methods flexible enough to accept a whole family of related types, without throwing away type safety. To motivate why they exist, we are going to start with a concrete problem and build up from there, so that by the time wildcards are introduced, you can see exactly what gap they are filling.


Setting the Scene: A Simple Class Hierarchy

We’ll work with three classes throughout this post. There is nothing fancy with them, just a small inheritance chain:

class Animal {
    String name;
    Animal(String name) { this.name = name; }
    String sound() { return "..."; }
}

class Dog extends Animal {
    Dog(String name) { super(name); }
    @Override String sound() { return "Woof"; }
}

class Cat extends Animal {
    Cat(String name) { super(name); }
    @Override String sound() { return "Meow"; }
}

Both Dog and Cat extend Animal, and like every Java class, Animal itself ultimately extends Object. We can visualise this as the following hierarchy:

Object Animal Dog Cat
Class hierarchy. Arrows point from child to parent (the direction of extends).

With these classes in place, the following list declarations all compile and work exactly as you’d expect:

List<Dog>    dogs  = List.of(new Dog("Rex"), new Dog("Buddy"));
List<Cat> cats  = List.of(new Cat("Whiskers"), new Cat("Luna"));
List<Animal> mixed = List.of(new Dog("Max"), new Cat("Cleo"));

The Problem: Generic Invariance

Let’s say we want a method that iterates over a list of animals and prints what each one says. One thing you may try is writing a method which accepts a List<Animal>, becasue Dog and Cat are in an is-a relationship with Animal (refer to the diagram above).

import java.util.List;

public class Main {

    static class Animal {
        String name;
        Animal(String name) { this.name = name; }
        String sound() { return "..."; }
    }

    static class Dog extends Animal {
        Dog(String name) { super(name); }
        @Override String sound() { return "Woof"; }
    }

    static class Cat extends Animal {
        Cat(String name) { super(name); }
        @Override String sound() { return "Meow"; }
    }
    static void makeNoise(List<Animal> animals) {
        for (Animal a : animals) {
            System.out.println(a.name + " says " + a.sound());
        }
    }
    public static void main(String[] args) {
        List<Dog> dogs = List.of(new Dog("Rex"), new Dog("Buddy"));
        List<Cat> cats = List.of(new Cat("Whiskers"), new Cat("Luna"));
        List<Animal> mixed = List.of(new Dog("Max"), new Cat("Cleo"));
        makeNoise(dogs);  // ← causes a compile error
    }
}
Compiler Error
error: incompatible types: List<Dog> cannot be converted to List<Animal>
        makeNoise(dogs);
                  ^

You may be surprised by this error message. Dog IS an Animal, we just defined that. So why won’t the compiler accept a List<Dog> where a List<Animal> is expected?

This is generic invariance: the subtype relationship between two types does not carry over into their generic parameterisations. Even though Dog is a subtype of Animal, List<Dog> is not a subtype of List<Animal>. They are completely unrelated types as far as the compiler is concerned.

Compare this with arrays, which are covariant in Java, meaning Animal[] arr = new Dog[3] actually compiles. That design decision turned out to be a mistake: you can write arr[0] = new Cat(“Oops”)`and get an `ArrayStoreException` at runtime. Generics were designed to catch these problems at compile time instead, which is why they are stricter. Invariance is a feature, not a bug.

Think about it from the compiler’s perspective. If `List<Dog>` were a subtype of `List<Animal>`, you could pass a dog list to any method expecting an animal list, and that method could then call `animals.add(new Cat(“Cleo”))`, suddenly inserting a cat into what was supposed to be a list of dogs. The type system would be broken. Invariance prevents that.

So how do we write `makeNoise` in a way that accepts *any* list of animals, including lists of subtypes? That’s where wildcards come in.


Upper-Bounded Wildcards: `? extends T`

The fix is a small change to the method signature. Replace `List<Animal>` with `List<? extends Animal>`:

    static void makeNoise(List<? extends Animal> animals) {
        for (Animal a : animals) {
            System.out.println(a.name + " says " + a.sound());
        }
    }

The `?` is the wildcard. It stands for “some unknown type.” The `extends Animal` part puts an upper bound on it: whatever that unknown type is, it must be `Animal` or one of its subtypes. So `List<? extends Animal>` reads as: “a list of some type that is `Animal` or below in the hierarchy.”

With this change, `makeNoise(dogs)`, `makeNoise(cats)`, and `makeNoise(mixed)` all compile and work. The diagram below shows which types are now covered:

Object Animal Dog Cat
Figure 2: List<? extends Animal> accepts lists whose element type is highlighted in yellow.

Why you can’t add to a `? extends` list

There’s a trade-off. Once you use an upper-bounded wildcard, the compiler will not let you add elements to the list:

    static void tryToAdd(List<? extends Animal> animals) {
        animals.add(new Cat("Cleo"));  // compile error!
    }
Compiler Error
error: no suitable method found for add(Cat)
        animals.add(new Cat("Cleo"));
               ^
    method List.add(capture#1 of ? extends Animal) is not applicable
      (argument mismatch; Cat cannot be converted to capture#1 of ? extends Animal)

Why? Because `List<? extends Animal>` means “a list of some specific but unknown subtype of `Animal`.” At runtime, that list could be a `List<Dog>`. If Java let you add a `Cat` to it, you’d have just slipped a cat into a list that was supposed to contain only dogs. The compiler blocks writes entirely because it simply cannot verify that the element you’re adding matches the list’s actual (unknown) type parameter.

Reading, on the other hand, works perfectly. No matter whether the list is a `List<Dog>`, a `List<Cat>`, or a `List<Animal>`, every element is guaranteed to be at least an `Animal`. So the compiler is happy to give you back an `Animal` reference:

    static Animal getFirst(List<? extends Animal> animals) {
        Animal a = animals.get(0);  // ✓ always at least an Animal
        return a;
    }

You can call any method defined on `Animal` on that reference. What you cannot do is go the other way and ask for a `Dog` specifically, because the list might be a `List<Cat>`:

    static void example(List<? extends Animal> animals) {
        Animal a = animals.get(0);  // ✓ fine - Animal is the bound
        Dog d    = animals.get(0);  // compile error! might not be a Dog
    }

There is a subtlety worth being explicit about here. Suppose you know that at runtime the list really *is* a `List<Dog>`. You might expect that the compiler would let you read elements as `Dog` in that case. It won’t. The compiler reasons only about what the wildcard tells it, which is “some subtype of `Animal`.” That subtype is unknown at compile time, so `Animal` is the strongest type you are allowed to use. Even if the underlying list is genuinely a `List<Dog>`, reading through a `List<? extends Animal>` reference will only give you back an `Animal`.

So the rule is: `? extends T` lists let you *read* elements as `T` (and `T` is the best you get), but you cannot *write* to them at all.


Lower-Bounded Wildcards: `? super T`

Now let’s look at the flip side. Suppose we want a method that *adds* a couple of dogs into a list. Here’s a natural first attempt:

import java.util.ArrayList;
import java.util.List;

public class Main {

    static class Animal {
        String name;
        Animal(String name) { this.name = name; }
        String sound() { return "..."; }
    }

    static class Dog extends Animal {
        Dog(String name) { super(name); }
        @Override String sound() { return "Woof"; }
    }

    static class Cat extends Animal {
        Cat(String name) { super(name); }
        @Override String sound() { return "Meow"; }
    }

    static void makeNoise(List<? extends Animal> animals) {
        for (Animal a : animals) {
            System.out.println(a.name + " says " + a.sound());
        }
    }
    static void addDogs(List<Dog> list) {
        list.add(new Dog("Rex"));
        list.add(new Dog("Buddy"));
    }
    public static void main(String[] args) {
        List<Dog> dogs = List.of(new Dog("Rex"), new Dog("Buddy"));
        List<Cat> cats = List.of(new Cat("Whiskers"), new Cat("Luna"));
        List<Animal> mixed = List.of(new Dog("Max"), new Cat("Cleo"));

        makeNoise(dogs);
        makeNoise(cats);
        makeNoise(mixed);
        List<Animal> animalShelter = new ArrayList<>();
        addDogs(animalShelter);  // Does not work!
    }
}
Compiler Error
error: incompatible types: List<Animal> cannot be converted to List<Dog>
        addDogs(animalShelter);
                ^

We’re back to invariance. `List<Animal>` is not a subtype of `List<Dog>`, so we can’t pass it to a method expecting a `List<Dog>`. But adding dogs to a list of animals should be perfectly fine, shouldn’t it? A dog is an animal. The problem is that the method signature is too narrow.

What we actually want to express is: “give me any list that can hold a `Dog`, meaning its element type is `Dog` or anything above it in the hierarchy.” That’s exactly what the lower-bounded wildcard gives us:

    static void addDogs(List<? super Dog> list) {
        list.add(new Dog("Rex"));
        list.add(new Dog("Buddy"));
    }

`List<? super Dog>` means “a list whose element type is `Dog` or some supertype of `Dog`.” The compiler now knows that whatever the actual type parameter is, it can hold a `Dog`. Both of these calls work:

    List<Animal> animalShelter = new ArrayList<>();
    addDogs(animalShelter);  // ✓ works

    List<Object> objectShelter = new ArrayList<>();
    addDogs(objectShelter);  // ✓ also works

The diagram below shows which types are accepted by `? super Dog`:

Object Animal Dog Cat
Figure 3: List<? super Dog> accepts lists whose element type is highlighted in yellow.

Notice that `Cat` is excluded. A `List<Cat>` is not a supertype of `Dog`, so it does not qualify. The bound runs strictly up the inheritance chain from `Dog`.

There is an important trade-off here that mirrors what we saw with `? extends`. With an upper-bounded wildcard you could read but not write. With a lower-bounded wildcard it is the reverse: writing works, but reading is severely limited. Let’s look at both sides carefully.

**Writing the bound (or a subtype) is safe.** A `Dog` is a `Dog`, is an `Animal`, is an `Object`. So a `Dog` fits into every list that `? super Dog` might actually be at runtime:

    List<? super Dog> list = someList;  // could be List<Dog>, List<Animal>, or List<Object>
    list.add(new Dog("Rex"));           // ✓ Dog fits in all three possibilities

**Writing a supertype is forbidden.** This is the part that trips students up. `? super Dog` does not mean “you can write anything.” You can only write a `Dog` (or a subtype of `Dog`). Writing a supertype is rejected:

    List<? super Dog> list = someList;
    list.add(new Animal("Generic"));   // compile error! Animal doesn't fit if list is List<Dog>
    list.add(new Object());            // compile error! Object doesn't fit List<Dog> or List<Animal>

You might think: “but if the underlying list is really a `List<Animal>`, then adding an `Animal` would be fine!” True, but the compiler does not know whether the underlying list is `List<Animal>` or `List<Dog>`. It has to assume the most restrictive possibility, which is `List<Dog>`. An `Animal` does not fit there, so the write is rejected.

**Reading gives you back only `Object`.** For the same reason, reading is limited. The list could be a `List<Dog>`, a `List<Animal>`, or a `List<Object>`. The only type that covers all three possibilities is `Object`:

    static void example(List<? super Dog> list) {
        Object obj = list.get(0);   // ✓ Object is the only guaranteed type
        Animal a   = list.get(0);   // compile error! might be List<Dog> where elements aren't Animal refs
        Dog    d   = list.get(0);   // compile error! same reason
    }

Lower-bounded wildcards are therefore best suited to write operations, not read operations.


Quick Note: The Unbounded Wildcard `<?>`

You’ll also encounter `List<?>` in documentation and library source code. This is simply shorthand for `List<? extends Object>`, meaning a list of any type at all with no restrictions. It’s useful when you need to accept any generic list and you genuinely don’t care what’s inside it (for example, just to check its size or pass it along). The same write restriction applies: you can’t add anything to a `List<?>` because its element type is completely unknown.


Summary Cheat Sheet

| What you want to do | Wildcard to use | Reads as |

|—|—|—|

| Read from a collection (accept any subtype) | `? extends T` | “some type that is *T* or below” |

| Write to a collection (accept any supertype) | `? super T` | “some type that is *T* or above” |

| Don’t care about the element type at all | `?` | “any type whatsoever” |

> The key insight to hold onto: generics are invariant by design, and that invariance is what makes them type-safe. Wildcards are how you deliberately opt back into flexibility, in a controlled, bounded way, when you need a method to work across a family of related types. Upper bounds let you read broadly; lower bounds let you write broadly. Once you can reason about which direction you need the flexibility to go, picking the right wildcard becomes straightforward.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply