Skip to content

← The Type System as a Design Tool step 15 of 24

Hard Framework

Producer, Consumer, Cell: variance by member set

You do not declare variance under PEP 695. The checker infers it from your method set — which means adding a public mutable attribute to a generic class is a breaking API change, and nothing in the diff says so.

The inference algorithm is short enough to memorise:

  • the parameter appears only in return positions -> covariant
  • only in parameter positions -> contravariant
  • in a public mutable attribute, or in both positions -> invariant

That last clause is the one that bites. A self.items: list[T] is both readable and writable, and list[T] is itself invariant in T, so a single innocuous attribute collapses the whole class to invariance. Make it private and expose it through a read-only @property returning a tuple[T, ...], and covariance comes back.

This is exactly why ruff marks UP046 and UP047 — the rules that rewrite Generic[T] classes into PEP 695 syntax — as unsafe fixes. Migrating T = TypeVar("T", covariant=True) to class C[T] without understanding inference can silently change your public API’s variance and break downstream callers who were relying on the old assignability.

And there is no escape hatch through the old spelling: mypy 2.3 rejects TypeVar(..., infer_variance=True), even though CPython has supported it since 3.12. PEP 695 syntax is the only route to inferred variance under mypy.

The task

Write three generic classes whose member sets dictate their inferred variance, plus a function that consumes both:

class Producer[T]:      # covariant   — T only ever comes out
class Consumer[T]:      # contravariant — T only ever goes in
class Cell[T]:          # invariant   — T is a public mutable attribute

def pipe[T](producer: Producer[T], consumer: Consumer[T]) -> None: ...
def solve(names: list[str]) -> tuple[list[str], int, str]: ...
  • Producer[T] is constructed from a Sequence[T], stores it privately as a tuple[T, ...], and exposes it through a read-only items property.
  • Consumer[T] has put(self, item: T) -> None and log(self) -> list[str] returning str(item) for each item received in order. T must not appear in its attribute types — store the received items as list[object], or contravariance is lost the same way covariance is.
  • Cell[T] has a single public value: T set in __init__.
  • pipe puts every item of the producer into the consumer.

Animal and Dog are given, with Dog a subclass and __str__ returning "Dog(rex)".

solve builds a Dog per name and returns (consumer log, number of items the producer holds, str of the cell's value).

The variance witnesses are inside solve

These three lines are not decoration — they are the assertions:

producer: Producer[Animal] = Producer(dogs)   # Producer[Dog] -> Producer[Animal]
sink: Consumer[Dog] = Consumer[Animal]()      # Consumer[Animal] -> Consumer[Dog]
cell: Cell[Animal] = Cell(Animal("origin"))   # Cell[Dog] would NOT satisfy this

If Producer is not covariant, line 1 is an assignment error. If Consumer is not contravariant, line 2 is. Both are load-bearing: your solution does not type-check unless the member sets are right. pipe(Producer(dogs), sink) type-checks for the same reason — T solves to Dog, with the Consumer[Animal] accepted by contravariance.

For Cell, try it yourself: add

a: Cell[Animal] = Cell[Dog](Dog("d"))
b: Cell[Dog] = Cell[Animal](Animal("a"))

and confirm mypy rejects both. A cell you can read and write cannot be substituted in either direction, and seeing both errors is more convincing than reading the rule.

Why contravariance feels backwards

Consumer[Animal] being usable where a Consumer[Dog] is required reads wrong until you say it out loud: a thing that can accept any animal can certainly be handed dogs. The substitution goes the other way from the type parameter, which is precisely what “contravariant” names. Callbacks, sinks, comparators and handlers all behave this way — see the retry problem for the version of this that appears in every review of callback-heavy code.