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

by Eric Hanson, Backend Developer at Clean Systems Consulting

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

Why Some Software Projects Are Doomed From the Start

“We know this won’t work… but we have to do it anyway.” Sometimes, failure isn’t accidental — it’s scheduled.

Read more

Freelancers vs Agencies vs In-House Teams

“Should we hire freelancers, an agency, or build an in-house team?” The answer isn’t about which is best—it’s about what your situation actually needs.

Read more

Feeling Underqualified? How to Fake Confidence (Safely)

Everyone feels underqualified sometimes, especially early in their career. Here’s how to appear confident without pretending to be an expert you’re not.

Read more

Accidentally Publishing Half-Finished Code: How to Recover

You push your code, confident everything is ready… and then you realize part of it wasn’t supposed to go live.

Read more