Generic type contracts: one shape, many safe types

Reuse parameterized classes and functions across element types, then compare Java invariance and wildcards with Python checker-oriented generics. Methods and type contracts · Object-oriented programming.

A clerk hands you a tray labelled "Invoices". Any slip you take from it should be an invoice, and any slip you drop in keeps the label honest only if it is an invoice too. Now a colleague offers a second tray labelled "Special invoices" and says, "treat it as an Invoices tray." Reading from it is fine. Dropping an ordinary invoice into it would break its label. A generic type is that label, and this lesson is about when a label may be widened safely, and who checks it once the program runs.

One element type, one promise

A type parameter names the element once, so a container can tie what it accepts to what it returns. For List<Invoice>, add takes an Invoice and get hands back an Invoice. A generic method does the same job for one call: in static <T> T firstOf(List<T> items), whatever T the argument has is the type that comes back. Python 3.12 and later write the parameter in brackets after the name, as in class Shelf[T].

Subtype values still fit. A SpecialInvoice is an Invoice, so invoices.add(new SpecialInvoice()) is accepted. The list types themselves are another matter. Java treats List<Invoice> and List<Object> as different types, and neither is a subtype of the other. This is invariance. Assigning the Invoice list to a List<Object> is refused because through the wider name, anyone could add a Receipt, which is not an Invoice, to a list that other code still reads as Invoices.

A list type is not its element's family

Read the steps as text

  1. List S1 holds one Invoice, i1. The variable invoices looks at it as a List<Invoice>.
  2. The compiler refuses List<Object> wide = invoices because List<Invoice> is not List<Object>. Invoice is an Object, yet the list types differ; allowing it would let a Receipt into S1.
  3. The compiler refuses invoices.add(new Receipt()) because Receipt is not an Invoice. A List<Invoice> takes Invoice items only, so the Receipt never enters S1.
  4. invoices.add puts i3, a SpecialInvoice, into list S1. A SpecialInvoice is an Invoice, so it fits. Subtype values fit an exact list.
  5. latest now holds i3, a SpecialInvoice. The read is typed as the Invoice the list promises, whichever subclass sits in the slot.
  6. S1 holds only Invoice objects, because the compiler refused both unsafe lines before anything ran.

Producer and consumer views

A list you only read from produces values for you, and a list you only write into consumes them. A method that only reads can accept List<? extends Invoice>: a list of some Invoice type, with the exact type unknown. Every element is at least an Invoice, so reading is safe. Adding is not. The compiler holds the unknown element type as a fresh placeholder for the length of the check (it calls this capture and prints it as CAP#1), and no value is known to match it, not even a plain Invoice, because the real list might hold only SpecialInvoice objects.

A method that only writes can accept List<? super SpecialInvoice>: a list that takes a SpecialInvoice. Adding is safe whichever wider list it really is, but a read promises only Object. "Producer extends, consumer super" names the operation each view keeps: reads for extends, writes for super.

Producer and consumer views

Read the steps as text

  1. List S1 holds a SpecialInvoice and list S2 holds an Invoice. The variable special looks at S1 as List<SpecialInvoice>, and sink looks at S2 as List<Invoice>.
  2. Variable producer now looks at list S1 as List<? extends Invoice>. A view for reading S1, because everything behind it is at least an Invoice.
  3. seen now holds i1, a SpecialInvoice. Reads through an extends view always yield an Invoice.
  4. The compiler refuses producer.add(new Invoice()) because List<? extends Invoice> may be a list of a narrower type, so no Invoice is guaranteed to fit. The element type is an unknown capture, so even a plain Invoice is refused.
  5. Variable consumer now looks at list S2 as List<? super SpecialInvoice>. A view for writing to S2, because a SpecialInvoice fits whatever list this can be.
  6. consumer.add puts i4, a SpecialInvoice, into list S2. Writing a SpecialInvoice is exactly what this view allows, so S2 grows.
  7. The compiler refuses SpecialInvoice back = consumer.get(1) because this view only promises an Object. The view says where a SpecialInvoice may go, not what comes out.
  8. any now holds i4, a SpecialInvoice. Object is the only type a super view can promise on the way out.
  9. Read through extends, write through super. Each view gave up what it could not prove safe.

This complete program was compiled and run. It prints 1 2: fileInto accepted a list of SpecialInvoice and a list of Invoice, and count read both.

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

class Invoice {}
class SpecialInvoice extends Invoice {}

public class Main {
    static int count(List<? extends Invoice> source) {
        return source.size();
    }

    static void fileInto(List<? super SpecialInvoice> destination) {
        destination.add(new SpecialInvoice());
    }

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

    public static void main(String[] args) {
        List<SpecialInvoice> specials = new ArrayList<>();
        List<Invoice> invoices = new ArrayList<>();
        invoices.add(new SpecialInvoice());
        fileInto(specials);
        fileInto(invoices);
        System.out.println(count(specials) + " " + count(invoices));
        SpecialInvoice s = firstOf(specials);
        Invoice i = firstOf(invoices);
        // SpecialInvoice bad = firstOf(invoices);        // does not compile
        // List<Invoice> wide = specials;                 // does not compile
        // specials.add(new Invoice());                   // does not compile
        List<? extends Invoice> view = specials;
        // view.add(new Invoice());                       // does not compile
    }
}

Each commented line fails when restored. A plain Invoice cannot go into the SpecialInvoice list, and firstOf fixes T to Invoice, so it cannot fill a SpecialInvoice variable. The widening alias reports incompatible types: List<SpecialInvoice> cannot be converted to List<Invoice>, and the add through the extends view reports that Invoice cannot be converted to a captured type.

Where the promise really lives

Java checks generic types while compiling, then erases them. The running ArrayList stores objects and has forgotten which element type the source named. The compiler keeps its promise by inserting a cast at a typed read. A raw type such as plain List stops the compiler tracking the element type through that alias and only warns, so a mistake can enter quietly and surface later at a read.

Where the promise really lives

Read the steps as text

  1. The Invoice list S1 holds i1, and invoices is about to get a second name for it.
  2. Variable legacy now looks at list S1 as a raw List. The compiler stops tracking the element type through this name.
  3. The compiler only warns about legacy.add(new Receipt()). legacy.add puts i2, a Receipt, into list S1. Nothing at run time objects, because the running list never stored its type argument.
  4. first now holds i1, an Invoice. Slot 0 really is an Invoice, so the cast the compiler inserted at this read succeeds.
  5. any now holds i2, a Receipt. Reading as Object needs no cast, so the stray Receipt comes back unchallenged.
  6. The cast fails: i2 is a Receipt, not an Invoice, so Java throws ClassCastException. The promise was only ever enforced by the cast inserted at this typed read, long after the mistake.
  7. Generic types are checked before the program runs; afterwards a list holds only objects, so a mistake can surface far from where it entered.

The program below was compiled and run. It prints 2 and read as Object: Receipt, then stops with java.lang.ClassCastException: class Receipt cannot be cast to class Invoice. The add made the mistake and the typed read noticed it.

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

class Invoice {}
class Receipt {}

public class Main {
    @SuppressWarnings({"rawtypes", "unchecked"})
    public static void main(String[] args) {
        List<Invoice> invoices = new ArrayList<>();
        invoices.add(new Invoice());
        List legacy = invoices;
        legacy.add(new Receipt());
        System.out.println(invoices.size());
        Object any = invoices.get(1);
        System.out.println("read as Object: " + any.getClass().getSimpleName());
        Invoice second = invoices.get(1);
        System.out.println("not reached");
    }
}

Python's generics are a message to a type checker. A tool such as mypy reads the annotations and flags the widening alias, but the interpreter never checks them, so every line runs.

A checker the interpreter ignores

Read the steps as text

  1. Shelf S1 holds one SpecialInvoice, i1. The variable special is annotated Shelf[SpecialInvoice].
  2. Variable again now looks at list S1 as Shelf[SpecialInvoice]. The annotation matches, so the checker is satisfied.
  3. The type checker flags wide: Shelf[Invoice] = special because Shelf[SpecialInvoice] is not Shelf[Invoice], but Python runs it and wide looks at list S1. Shelf is invariant, and the interpreter never checks annotations.
  4. wide.add puts i2, an Invoice, into list S1. On its own this line passes, because wide says Invoice; the harm came from the flagged alias.
  5. first now holds i2, an Invoice. special promises SpecialInvoice, so the checker is content, yet Python returns the plain Invoice.
  6. Python raised nothing; only the checker flagged the alias, and the shelf annotated for SpecialInvoice now holds a plain Invoice.

Run with Python 3.12 or later, this prints ['SpecialInvoice', 'Invoice'] Invoice and raises nothing. Checked with mypy, it reports one error, on the wide line: Incompatible types in assignment (expression has type "Shelf[SpecialInvoice]", variable has type "Shelf[Invoice]").

class Invoice:
    pass


class SpecialInvoice(Invoice):
    pass


class Shelf[T]:
    def __init__(self, *items: T) -> None:
        self.items: list[T] = list(items)

    def add(self, item: T) -> None:
        self.items.append(item)

    def get(self, i: int) -> T:
        return self.items[i]


special: Shelf[SpecialInvoice] = Shelf(SpecialInvoice())
wide: Shelf[Invoice] = special  # the checker flags this line
wide.add(Invoice())  # fine on its own: wide says Invoice
first: SpecialInvoice = special.get(1)
print([type(x).__name__ for x in special.items], type(first).__name__)

Choose the view from the operation

A function that only displays invoices should accept List<? extends Invoice>, so callers can pass a list of any Invoice subtype. A function that files invoices into a list the caller owns should accept List<? super SpecialInvoice>. A container the same code both reads and writes keeps its exact type. Python has no wildcards, so keep a mutable container's annotation exact and let the checker report mismatches.

When values arrive from outside the program, such as a form or a JSON file, no generic type validates them in either language. Check them yourself before they enter the container, for example by rejecting a record whose total is not a number before adding it to a List<Invoice>.

Recap

  • A type parameter ties what a container accepts to what it returns; subtype values fit an exact list, while the list types stay unrelated.
  • Read through extends and write through super; each view gives up the operation it cannot prove safe.
  • Java checks generics at compile time and erases them, so a polluted list fails later at a typed read; Python's annotations reach only a checker.
Go deeper

The Java compiler can use capture conversion to type-check a helper that works with one unknown wildcard type, even though the caller never names that type. Reifiable types are the limited cases whose relevant type information remains available to runtime operations; the type argument of an ordinary parameterized type such as List<String> cannot be checked at run time. Python's newer type-parameter syntax and TypeVar bounds or constraints express different checker relationships, so choose the smallest annotation that describes the API rather than importing Java wildcard vocabulary into Python.

Continue to nested types and enclosing scope when the reusable type itself needs a helper declaration or a carefully bounded capture of surrounding state. Practise list element contracts in Linked List Fundamentals, where each node and link must preserve its intended shape.