Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Designing Type-Safe Java APIs with Variance and PECS

Hello! Last lesson made an order snapshot’s contents stable after construction. Now consider an API that copies order events into an audit collection. Even if each event is a well-designed value object, the API can still reject valid callers—or accept unsafe operations—depending on its generic types.

In about 40 minutes, you’ll use Java’s variance rules and PECS (Producer Extends, Consumer Super) to design that API, then check its behavior with the compiler. This is a useful interview skill: the strongest answer explains not just which wildcard to write, but what operation the type permits and why.


Why the obvious method signature is too narrow

Suppose OrderPlaced implements OrderEvent. It is tempting to write a copy method that accepts an Iterable<OrderEvent> and a Collection<OrderEvent>. But a List<OrderPlaced> cannot be passed as the source. Java generics are invariant: even though every OrderPlaced is an OrderEvent, a List<OrderPlaced> is not a List<OrderEvent>.

The restriction prevents a real type error. If Java treated List<OrderPlaced> as List<OrderEvent>, code holding the latter reference could add an OrderCancelled to a list intended to contain only OrderPlaced objects.

Watch Geekific’s Generics and Wildcards in Java for the compiler-level explanation. The two middle segments establish the separate jobs a source and destination must perform; the final segment names the PECS rule.

Generics and Wildcards in Java | Part 2 | Invariance vs Covariance vs Contravariance | Geekific

Geekific’s “Generics and Wildcards in Java” contrasts invariant generic collections with the flexibility provided by bounded wildcards.

Watch invariance for why a compile-time rejection is useful. Then watch extends for retrieving elements, super for adding them, and PECS for the rule that connects those choices. At each example, identify which collection supplies values and which receives them.

Oracle’s wildcard diagram shows the assignability relationships the method will rely on. Follow the arrows upward toward broader wildcard types; do not infer an assignment between two concrete list types merely because their element types are related.

Oracle’s diagram of Java `List` wildcard subtyping. Upward arrows show, for example, that `List<Integer>` can be used as `List<? extends Number>` or `List<? super Integer>`; it does not show `List<Integer>` as a subtype of `List<Number>`.

Choose a wildcard by the collection’s job

A producer supplies elements to your method. If a parameter is Iterable<? extends OrderEvent>, each element you retrieve is safely usable as an OrderEvent. The actual iterable might contain only OrderPlaced events, only OrderCancelled events, or some other subtype. Because you do not know which, you cannot safely add an arbitrary OrderEvent to a List<? extends OrderEvent>.

A consumer receives elements from your method. If a parameter is Collection<? super OrderPlaced>, you may add an OrderPlaced: a collection of OrderPlaced, OrderEvent, or Object can all hold one. But if you retrieve an element through that parameter, Java can promise only Object. The collection might actually be a Collection<Object> containing other kinds of objects.

Read the relevant parts of Dev.java’s Wildcards page to put precise language around those two guarantees.

Wildcards - Dev.java

Dev.java’s “Wildcards” explains why concrete generic types are invariant and gives practical guidelines for choosing wildcard bounds.

In “Wildcards and Subtyping,” read the subtyping explanation, from the opening paragraph through the paragraph explaining why the two concrete List types are unrelated. Then go to “Guidelines for Wildcard Use” and read the design guidelines, from the definitions of “in” and “out” variables through the advice on return types. Focus on what the method does with each parameter, rather than on the name of the parameter.

There is a subtle distinction from the previous lesson: ? extends OrderEvent does not make a collection immutable. It restricts what code can add through that reference; the underlying collection might still be mutable. Conversely, List.copyOf(...) makes the resulting list unmodifiable, but says nothing about which wildcard an API should use to accept it. Mutability and generic variance answer different questions.


Build an API that connects source and destination

Here is a complete Java example. Its copyInto method works for order events, but the type relationship is useful for other element types too.

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

public class EventTransfer {
    interface OrderEvent {
        String orderId();
    }

    record OrderPlaced(String orderId) implements OrderEvent {}
    record OrderCancelled(String orderId) implements OrderEvent {}

    static <E> int copyInto(
            Iterable<? extends E> source,
            Collection<? super E> destination) {
        int count = 0;
        for (E event : source) {
            destination.add(event);
            count++;
        }
        return count;
    }

    public static void main(String[] args) {
        List<OrderPlaced> placed =
                List.of(new OrderPlaced("order-7"));
        List<OrderCancelled> cancelled =
                List.of(new OrderCancelled("order-8"));

        List<OrderEvent> audit = new ArrayList<>();
        copyInto(placed, audit);
        copyInto(cancelled, audit);

        List<Object> mixed = new ArrayList<>();
        copyInto(placed, mixed);

        System.out.println(audit.size()); // 2
        System.out.println(mixed.size()); // 1
    }
}

Read the signature from the method body outward. The loop retrieves values from source, so ? extends E is enough: every retrieved value can be held in an E variable. The call to add supplies an E to destination, so ? super E is enough: the destination can accept each value. The method’s named type parameter, <E>, connects those two promises without a cast.

That connection matters. The compiler accepts a source of OrderPlaced and a destination of OrderEvent or Object. It will reject a destination of OrderCancelled for that source: OrderCancelled is not a type that can hold an OrderPlaced. If this method were intended only for order events, you could add a bound—<E extends OrderEvent>—while keeping the producer and consumer wildcards.

The method assumes copying does not modify the source during iteration. In particular, do not pass the same mutable list as both source and destination to this implementation.


Use compiler feedback as a short drill

Put the example in EventTransfer.java and compile and run it. Then add the following lines inside main, one commented line at a time, and use the compiler’s response to check the guarantee you expected:

List<OrderPlaced> mutablePlaced = new ArrayList<>(placed);
List<? extends OrderEvent> readable = mutablePlaced;
OrderEvent first = readable.get(0);

// Does not compile: readable might refer to a List<OrderCancelled>.
// readable.add(new OrderPlaced("order-9"));

List<? super OrderPlaced> writable = audit;
writable.add(new OrderPlaced("order-10"));

// Does not compile: an element retrieved through writable is only Object.
// OrderPlaced exact = writable.iterator().next();

// Does not compile: a destination of OrderCancelled cannot hold OrderPlaced.
// copyInto(placed, new ArrayList<OrderCancelled>());

The readable example is deliberately backed by an ArrayList, not an unmodifiable list. Its failed add is therefore a static type-safety restriction, not an immutability rule.

PECS is a guide to element flow, not an instruction to place a wildcard everywhere. If a method needs to retrieve and insert the same element type through one parameter, an exact type such as List<E> may be the clearer contract. If it needs only type-independent operations such as size(), List<?> can be appropriate. Avoid wildcard return types when a concrete or named generic return type can give callers a more useful guarantee.


Key takeaways

Java’s concrete generic collection types are invariant. For a flexible, type-safe API, identify which parameter produces elements and which consumes them: use ? extends E for the source and ? super E for the destination. A named type parameter links the two, letting the compiler reject incompatible transfers without runtime casts.

Next, you’ll turn from type relationships to object relationships: deciding when composition gives a design a better boundary than inheritance.

Can't find a good explanation? Sign up and we'll make it for you

Sign up