Java Generics Beyond `List<T>` — Wildcards, Bounds, and When They Actually Matter

by Arif Ikhsanudin, Backend Developer

Why wildcards exist at all

Java generics are invariant. List<String> is not a subtype of List<Object>, even though String is a subtype of Object. This surprises most developers the first time they hit it:

List<String> strings = new ArrayList<>();
List<Object> objects = strings; // compile error — not assignable

The reason is correctness. If List<String> were assignable to List<Object>, you could do this:

List<Object> objects = strings; // hypothetically allowed
objects.add(42);                // adds an Integer to what is actually a List<String>
String s = strings.get(0);     // ClassCastException at runtime

Invariance prevents this. But invariance also means you can't write a single method that operates on a List of any type without reaching for Object or raw types. Wildcards are the escape hatch — they express variance at the use site without breaking type safety.

Upper-bounded wildcards: ? extends T

List<? extends Number> means "a List of some specific type that is Number or a subtype of Number." The specific type is unknown at the call site, but the compiler knows it's at least a Number.

This makes reading safe and writing impossible:

public double sum(List<? extends Number> numbers) {
    double total = 0;
    for (Number n : numbers) {
        total += n.doubleValue(); // safe — every element is at least a Number
    }
    return total;
}

This method accepts List<Integer>, List<Double>, List<BigDecimal> — any list whose elements are Numbers. Without the wildcard, you'd need List<Number>, which accepts none of those due to invariance.

The write restriction is not a bug. If you could add to a List<? extends Number>, what would you add? The compiler doesn't know if it's a List<Integer> or a List<Double> — it only knows the element type is some subtype of Number. Adding an Integer to what's actually a List<Double> would break type safety. So the compiler rejects all adds except null:

public void broken(List<? extends Number> numbers) {
    numbers.add(42);      // compile error
    numbers.add(null);    // allowed — null is always safe
}

Upper-bounded wildcards are for producers — collections you read from.

Lower-bounded wildcards: ? super T

List<? super Integer> means "a List of some specific type that is Integer or a supertype of Integer." You don't know the exact type, but you know it can hold an Integer.

This makes writing safe and reading restricted:

public void addNumbers(List<? super Integer> list) {
    for (int i = 1; i <= 10; i++) {
        list.add(i); // safe — the list can hold at least Integers
    }
}

This method accepts List<Integer>, List<Number>, List<Object>. You can add Integer values to any of them safely. What you can't do is read elements back as Integer — the list might be a List<Object>, so elements come back as Object:

public void broken(List<? super Integer> list) {
    Integer i = list.get(0); // compile error — element type is unknown, only Object is safe
    Object o  = list.get(0); // fine — Object is always a safe read type
}

Lower-bounded wildcards are for consumers — collections you write into.

The producer-extends, consumer-super rule (PECS)

Joshua Bloch named this mnemonic in Effective Java and it remains the clearest formulation: Producer Extends, Consumer Super.

If a parameterized type produces values you consume (you read from it), use ? extends T. If a parameterized type consumes values you produce (you write into it), use ? super T. If it does both, use no wildcard — a concrete type parameter.

A canonical example that uses both:

public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (T element : src) {  // src produces — extends
        dest.add(element);   // dest consumes — super
    }
}

This method copies from any list of T or subtypes into any list of T or supertypes. Collections.copy in the standard library uses exactly this signature. Without wildcards, you'd need both lists to be exactly List<T>, which would reject copy(List<Object>, List<String>) even though that's perfectly safe.

Unbounded wildcards: <?>

List<?> means "a List of some unknown type." You can read elements as Object and add nothing except null. This is appropriate when the method genuinely doesn't care about the element type:

public void printSize(List<?> list) {
    System.out.println(list.size()); // size() doesn't care about element type
}

public boolean isFirstElementNull(List<?> list) {
    return list.isEmpty() ? false : list.get(0) == null;
}

Use <?> when the operation is entirely type-independent. If you're doing anything with the elements beyond null-checking or passing to Object methods, you need a bounded wildcard or a type parameter.

Type parameters vs wildcards — when to use each

Wildcards and type parameters solve different problems. The rule of thumb: use a type parameter when there's a relationship between types that needs to be expressed; use a wildcard when there's no relationship needed.

// Type parameter — expresses relationship: return type matches argument type
public <T> T firstElement(List<T> list) {
    return list.get(0);
}

// Wildcard — no relationship needed: just checking size
public boolean isEmpty(List<?> list) {
    return list.size() == 0;
}

// Type parameter — expresses relationship between two lists
public <T> void swap(List<T> list, int i, int j) {
    T temp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, temp);
}

When a method signature has multiple occurrences of the same wildcard where a relationship matters, that's a signal to use a type parameter instead:

// Wrong — wildcards can't express the relationship between src and dest types
public void badCopy(List<?> dest, List<?> src) { ... }

// Correct — type parameter expresses that they share a type T
public <T> void copy(List<? super T> dest, List<? extends T> src) { ... }

Multiple bounds and recursive type bounds

A type parameter can have multiple upper bounds:

public <T extends Comparable<T> & Serializable> T max(List<T> list) {
    return list.stream().max(Comparator.naturalOrder()).orElseThrow();
}

T must be both Comparable<T> and Serializable. Class bounds must come before interface bounds; there can be only one class bound but multiple interface bounds.

Recursive type bounds — where the bound references the type parameter itself — are how Comparable is usually constrained. T extends Comparable<T> means "T is comparable to itself," which is the constraint you want for natural ordering. Without the recursive bound, T extends Comparable loses type safety (raw type) and T extends Comparable<Object> is too permissive.

Where bounds and wildcards hurt more than they help

Deep nesting. Map<String, List<? extends Map<Integer, ? super Number>>> is type-safe and unreadable. When wildcard nesting exceeds one level, extract a named type or interface that carries the semantics.

Forcing callers to think about variance. If your API requires callers to understand whether to use ? extends or ? super, you've leaked implementation complexity. APIs that use wildcards internally but present concrete types externally — using wildcards only in method signatures that the caller never writes — are better designed than those that push the variance decision to the caller.

Wildcard capture in helper methods. Sometimes you need to capture a wildcard to operate on it:

public void process(List<?> list) {
    processHelper(list); // delegate to capture the wildcard
}

private <T> void processHelper(List<T> list) {
    T first = list.get(0);
    list.set(0, list.get(1)); // now legal — T is captured
    list.set(1, first);
}

This pattern is necessary but a sign that the outer API might be better expressed with a type parameter than a wildcard. If you consistently need to capture a wildcard immediately to do anything with it, reconsider whether the wildcard is serving the caller.

The practical test

Before adding a wildcard to a method signature, ask: does the wildcard make the method accept a strictly wider range of arguments that callers will actually pass? If yes, and if PECS applies, the wildcard is correct. If the wildcard is there because it seemed more general or because raw types felt wrong, it probably adds complexity without payoff.

Wildcards are not generics' advanced mode — they're a specific tool for expressing variance in method signatures. Most application code doesn't need them. Library and framework code, and any method that operates on collections of arbitrary subtypes, does.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Integration Tests Are Not Just Bigger Unit Tests

Integration tests and unit tests answer different questions. Treating integration tests as unit tests that cover more lines leads to slow, brittle suites that provide neither the speed of unit tests nor the coverage of true end-to-end tests.

Read more

Stop Mocking Things You Do Not Own

Mocking third-party libraries and external APIs directly in tests couples your test suite to the library's interface, not to your code's behavior. When the library changes, your tests break — even if your code still works correctly.

Read more

The Difference Between Fixing a Bug and Understanding a Bug

Fixing a bug makes the symptom go away. Understanding a bug tells you what was wrong with the system's assumptions and prevents the next three bugs in the same class. These are different activities that take different amounts of time and produce different outcomes.

Read more

Lazy vs Eager Loading in JPA — What Gets Loaded and When

JPA's fetch type determines when associated data is loaded from the database. Getting it wrong in either direction — too eager or too lazy — produces either unnecessary data transfer or N+1 queries. Here is the model and the correct defaults.

Read more