You wrote fulfill(List<Order> pack) because packing should only see orders. A caller arrives with List<SpecialOrder> — gift wraps that are orders — and the compiler refuses the call. Another still has a raw List from a 2003 library; a third has List<?> from a helper that forgot the element type. All three look like “a list of orders” until you try to pass them.

A generic type is a promise the compiler keeps: List<Order> stays a list of orders. This post owns that promise: type parameters, PECS, erasure, and why raw types still compile. Collection vs Map lives on the Collections Roadmap. The Stream pipeline is Streams. Keys still live or die by equals and hashCode — that contract is prior; generics do not replace it.

Same checkout domain as the rest of the series. SpecialOrder exists here only so PECS has a subtype:

public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}

public class Order { /* id, email, items, total, active */ }

public class SpecialOrder extends Order { /* gift note, rush flag */ }

Mental model

<Order> is a compile-time label on the use of a type, not a tag the JVM stores on the object. The compiler uses it to reject pack.add("SKU-MUG") and to type pack.get(0) as Order. After compilation the label is gone. That wipe is erasure, below.

List<Order>              get → Order          add ← Order
List                     raw: get → Object    add ← anything
List<?>                  get → Object         add almost never
List<? extends Order>    get → Order          add almost never     (producer)
List<? super Order>      get → Object         add ← Order          (consumer)

List<SpecialOrder> is not a List<Order>. If it were, fulfill could add a plain Order into a gift-only list. Java lists are invariant in their element type: List<A> and List<B> are unrelated even when B extends A. Wildcards are how you relax that on purpose.

Records take type parameters the same way classes do: record Box<T>(T value) {} — Records covers the rest of that syntax.

Type parameters on types and methods

A type parameter is a placeholder the caller fills. On a type it names the element (or key, or value) for every use of that type. On a method it names a type for that call.

List<E> is the type you already write. E is the parameter; Order is the argument. List.of infers the argument from the elements:

Order placed = new Order(/* … */);
List<Order> pack = List.of(placed);
List<Order> open = new ArrayList<>();
open.add(placed);

The diamond new ArrayList<>() copies the argument from the left-hand side. Spell the argument on at least one side.

A generic method declares its own parameters in front of the return type. first does not care that the element is an Order — it cares that the list and the return value share a type:

static <T> T first(List<T> items) {
    return items.get(0);
}

Order head = first(pack);           // T is Order
LineItem item = first(pack.get(0).items()); // T is LineItem

Bound the parameter when the method needs Order methods, not just “some T”:

static <T extends Order> BigDecimal totalOf(T order) {
    return order.total();
}

<T extends Order> is a bounded type parameter. You use it when you must name T — return it, store it, pass it to a second slot. If you only read Orders out of a list, a wildcard is enough; that is the next section.

The functional interfaces hub is full of the same idea: Function<T,R>, Consumer<T>, Predicate<T>. Those type parameters are why Order::id fills a Function<Order, String> slot instead of a one-off mapper type.

List<Order> vs a raw List

A raw type is the generic type written with no arguments: List, ArrayList, Function. It exists so Java 1.4 call sites still compile. It is not a style. New code does not use it.

List<Order> typed = new ArrayList<>();
typed.add(placed);              // ok
// typed.add("SKU-MUG");        // does not compile

List raw = typed;               // unchecked conversion
raw.add("SKU-MUG");             // compiles — warning only
Order boom = typed.get(1);      // ClassCastException at the cast

The raw add succeeded. The list that was supposed to be List<Order> now contains a String. The failure is not at the crime. It is at the next Order read — often in another method, another thread, another day. That mix is heap pollution.

A raw List still compiles because erasure made it legal, not because it is safe. Treat every raw type as a boundary with legacy code. In new code, write the arguments.

Note: List<Object> is not raw. It is a list that may hold any object, and the compiler will still stop you from assigning it to List<Order>. Raw is the missing <>. List<?> is the unknown element. Those three are different types; do not use them as synonyms.

Erasure

The compiler wipes type arguments before bytecode. At runtime a List<Order> and a List<LineItem> are the same class: List. The JVM does not re-check Order on every get. That is why the raw add above was not a ClassCastException at the add.

Three consequences you will hit in real helpers:

You cannot new T(). There is no constructor to call after T is gone. Pass a Supplier<T>, a factory, or an instance.

static <T> List<T> wrap(T value) {
    // return new T();          // does not compile
    List<T> one = new ArrayList<>();
    one.add(value);
    return one;
}

You cannot instanceof List<Order>. The argument is not there to test. List<?> is the check that remains:

if (payload instanceof List<?>) {
    List<?> unknown = (List<?>) payload;
    // you still do not know the element type
}

You cannot create a generic array. new List<Order>[8] and new T[8] are illegal. Arrays are reified and covariant; mixing them with erased, invariant lists is how store-checks and heap pollution used to collide. Prefer List.

Erasure is also why a List<Order> and a List<LineItem> cannot overload a method — after wipe they are the same signature. If the compiler error looks like “erasure collision,” that is this rule, not a bug in your overload set.

PECS: producer extends, consumer super

You need fulfill to accept List<SpecialOrder> and List<Order> without letting it add a plain Order into the gift list. That is not List<Order>. That is a producer of Orders: you will only read.

PECS — Producer Extends, Consumer Super. A producer is a source you get from. A consumer is a sink you add / accept into.

static BigDecimal packTotal(List<? extends Order> pack) {
    BigDecimal sum = BigDecimal.ZERO;
    for (Order order : pack) {
        sum = sum.add(order.total());
    }
    return sum;
}

List<Order> open = List.of(placed);
List<SpecialOrder> gifts = List.of(new SpecialOrder(/* … */));
packTotal(open);    // ok
packTotal(gifts);   // ok — SpecialOrder produces Order
// pack.add(placed) inside packTotal would not compile

? extends Order means “some unknown subtype of Order.” get is safe as Order. add is rejected (except null) because the unknown subtype might be SpecialOrder, and a plain Order would pollute it.

The dual is a consumer of Orders: you will only put them in.

static void appendRush(List<? super Order> pack, Order rush) {
    pack.add(rush);
}

List<Order> open = new ArrayList<>();
List<Object> payload = new ArrayList<>();
appendRush(open, placed);      // ok
appendRush(payload, placed);   // ok — Object consumes Order
// appendRush(gifts, placed);  // does not compile — might not be a SpecialOrder

? super Order means “some unknown supertype of Order.” add(Order) is safe. get comes back as Object, because the unknown supertype might be Object.

The JDK already wrote PECS on the slots you fill every day. Function maps with Function<? super T, ? extends R>: the function consumes a T (or a supertype) and produces an R (or a subtype). Consumer<? super T> is the same consumer wildcard on forEach. Function.andThen is PECS in one signature:

// Function<T, R>
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after)

after consumes this function’s R and produces the next V. You do not invent a tighter type; you fill the wildcards the SAM already declared.

List.copyOf is the collections-side twin: copyOf(Collection<? extends E>) — a producer of E. That is why a List<SpecialOrder> can snapshot as List<Order> when you ask:

List<Order> snapshot = List.<Order>copyOf(gifts);

The factories post owns copyOf and List.of. The wildcard is why the call is legal.

Unbounded and bounded wildcards

List<?> is the unbounded wildcard: some unknown type. You can size, isEmpty, clear. You cannot add (except null). get is Object. Use it when the method genuinely does not care what the element is.

static boolean empty(List<?> any) {
    return any.isEmpty();
}

empty(open);
empty(gifts);
empty(List.of("SKU-MUG"));

List<? extends Order> is upper-bounded: unknown, but no higher than Order. List<? super Order> is lower-bounded: unknown, but no lower than Order. Those are the PECS pair. List<?> is List<? extends Object> in practice — a producer of Object.

Pick a named type parameter (<T extends Order>) when the method must mention the type twice — take a List<T> and return a T, or take two lists of the same unknown T. Pick a wildcard when the type appears once and you only produce or only consume.

var on a wildcard local does not keep the capture: the compiler projects ? to a type you could have written, so the next line does not leak a hidden CAP#1. Spell List<? extends Order> when the bound is the documentation; the var post is the rest of that rule.

Heap pollution and the checked boundary

Heap pollution is a List<Order> that contains a non-Order. Raw types, unchecked casts, and varargs of generic types are how it gets in. The crash is a later cast to Order.

When you must hand a typed list to a raw API, wrap it first. Collections.checkedList is the runtime guard List<Order> cannot be after erasure:

List<Order> orders = new ArrayList<>();
List<Order> checked = Collections.checkedList(orders, Order.class);

@SuppressWarnings("rawtypes")
List raw = checked;
raw.add("not an order");
// ClassCastException at the add — not at a later get

Catch the bad add at the boundary, not at a get ten frames down. Collections and Arrays owns checkedList / checkedSet / checkedMap. Use them at the raw edge. Do not wrap every list in the service for sport.

Note: @SuppressWarnings("unchecked") does not fix pollution. It hides the warning that you still have a raw or cast hole. Prefer a checked wrapper or a typed adapter over a blanket suppress on the method.

Pitfalls

Raw in new code. List pack = new ArrayList() compiles and throws away the promise. Write List<Order>. If a library returns raw, wrap at the call, do not infect your signature.

List<Order> where you meant a producer. packTotal(List<Order>) refuses List<SpecialOrder>. That is invariance, not a bug in the caller. Change the parameter to List<? extends Order> if you only read.

List<Object> as a “anything” parameter. Callers with List<Order> cannot pass it. List<?> is the anything-read parameter. List<? super Order> is the anything-write-Order parameter.

Arrays of parameterized types. new List<Order>[8] does not compile. Do not cast your way to List<Order>[] to please a varargs call. Take List<List<Order>> or a sequence of lists.

Over-wildcards. A private helper that both gets Orders and adds Orders wants List<Order>. PECS is for parameters that cross a type boundary. Applying ? extends and ? super to every field makes the next mutation a compile puzzle for no gain.

Unchecked casts to “fix” the caller. (List<Order>) unknown compiles with a warning and reintroduces pollution. If you do not know the element type, you do not have a List<Order>.

When not to reach for more generics: a method that only ever sees Order does not need <T extends Order>. A public API that means “these are orders” should say List<Order> (or List<? extends Order> if subtypes must flow in). Do not invent OrderList extends ArrayList<Order> — that is a custom List, and this series does not write those.

Cheat sheet

Type param     List<E> on a type     <T> T first(List<T>) on a method
Argument       List<Order>           diamond copies from the left
Bound          <T extends Order>     name T when you use it twice

Invariant      List<SpecialOrder> is not a List<Order>
PECS           producer  List<? extends Order>   get Order, no add
               consumer  List<? super Order>     add Order, get Object
Unbounded      List<?>   size / empty / clear    get Object

Raw            List      still compiles; heap pollution
Erasure        JVM sees List         no new T(), no instanceof List<Order>
Boundary       Collections.checkedList(list, Order.class)

Do             List<Order> pack      List<? extends Order> when you only read
Don't          raw List in new code  (List<Order>) unknown
               new T() / new List<Order>[]

Do:

  • Write type arguments on every List, Map, and SAM you own.
  • Use ? extends on parameters you only read, ? super on parameters you only write.
  • Put checkedList at the raw-library boundary.

Don’t:

  • Treat a raw List as a list of orders.
  • Suppress unchecked warnings instead of closing the hole.
  • Wildcard a list you both fill and read as Order.
  • Expect instanceof or new to see T.

Wrap-up

Generics are how List<Order> means orders: type parameters on types and methods, invariance so a gift list stays a gift list, PECS when a helper only produces or only consumes, erasure so the JVM still sees a raw List, and checkedList when a raw API would otherwise pollute the heap. Raw types still compile because they have to, not because you should write them.

Write the arguments. Wildcard the boundary. Leave Collection vs Map and the Stream pipeline to the posts that own them.

Next optional step in the series Checked vs unchecked, try-with-resources, and wrap at the lambda boundary. Exceptions: Checked, Unchecked, and When throw Beats return