Skip to content

← Structural Typing and the Hard Parts step 8 of 24

Hard Primitives

Variance in generic protocols

Getting variance wrong on a generic protocol produces errors at every call site, all of which look like the caller’s fault. The caller then “fixes” them with a cast, and the design defect propagates.

The rule, in one line

Variance is determined by where the type parameter appears in the members:

  • only in input positions (parameters) → contravariant. A Serializer[object] can be used wherever a Serializer[int] is required: something that can serialise anything can certainly serialise an int.
  • only in output positions (returns) → covariant. A Parser[int] can be used wherever a Parser[object] is required.
  • both → invariant.

A de facto covariant protocol cannot be declared invariant — that would break transitivity of assignability, and checkers reject it.

Why the starter fails

The starter uses legacy TypeVar("T"), which is invariant unless you say otherwise. And you cannot fix it with TypeVar("T", infer_variance=True): CPython has accepted that argument since 3.12, but mypy 2.3 rejects it. Under mypy the only route to inferred variance is PEP 695 syntax:

class Serializer[T](Protocol):
    def dump(self, value: T) -> str: ...

Declaring TypeVar("T", contravariant=True) and TypeVar("T_co", covariant=True) by hand also works, and is what you will meet in older code — but it needs two type variables where PEP 695 needs none.

Your task

Rewrite Serializer and Parser so that:

serializer: Serializer[object] = ReprSerializer()
int_parser:  Parser[int]       = IntParser()
bool_parser: Parser[bool]      = BoolParser()

roundtrip(n, serializer, int_parser)   # Serializer[object] where [int] wanted
parse_all(int_parser, raws)            # Parser[int] where [object] wanted

both type-check. roundtrip[T](value: T, serializer: Serializer[T], parser: Parser[T]) -> T is parser.load(serializer.dump(value)); parse_all(parser: Parser[object], raws: list[str]) -> list[str] returns repr of each parsed value.

def solve(ints: list[int], flags: list[bool], raws: list[str]) -> list[str]:

returns, in order: repr(roundtrip(...)) for each int, then for each flag, then parse_all(int_parser, raws).

A 3.15 footnote

Python 3.15 fixed the runtime parameter ordering for class B(A[T2], Protocol[T1, T2]) and made an unlisted type variable a TypeError rather than a silent mis-binding. If you write generic protocols with multiple parameters and mixed bases, that is the release where the runtime finally agrees with the checkers.