Variance in lists of generics

Just when I think I understand (co|in)variance…

I have a type hierarchy similar to this:

from typing import Any, Generic, TypeVar

class A:

class B:

AB = A | B

T_AB = TypeVar('T_AB', bound=AB)

class G(Generic[T_AB]):

g1 = G[A]()
g2 = G[B]()
g3 = G[str]()  # type error, as expected

Now I want a list of G.

First question, why is this allowed? Is it by design, or just something the type checkers don’t check?

g_any: list[G[Any]] = []  # no type error?

I settled on that because I initially used this, but I get an error:

# Type parameter "T_AB@G" is invariant, but "B" is not the same as "AB"
g_ab: list[G[AB]] = [g1, g2]

I know I can ‘get around it’ with

T_AB = TypeVar('T_AB', bound=AB, covariant=True)

but I presume there are some semantics I should guarantee if I’m doing to assert that?

This is by design, Any is a gradual type and bidirectionally compatible[1], it thus circumvents variance rules when used to bind a TypeVar. list[Any] is also compatible with any list, even though list is invariant. It’s equivalent to omitting the type parameter entirely, so list and list[Any] mean the same thing.

Correct, covariance means instances of your Generic are not allowed to accept any values of T_AB[2] they are only allowed to produce them[3], contravariance is the opposite case and invariance is the mixed case.

A little example to show why this is necessary, using Sequence which is covariant:

x: list[str] = ["foo", "bar", "baz"]
y: Sequence[object] = x  # OK: "str" is a subclass of "object"

# Now let's assume we extended Sequence with an "append" method
# without fixing its variance:
y.append(5)  # OK: "int" is a subclass of "object"

This will break the promise of x only containing str without raising a type error, because as far as the type checker knows everything is fine.

PEP-695 introduced the infer_variance parameter to TypeVar which will do the variance calculation for you, if you’re worried about getting it wrong. Prior to Python 3.12 you can use it by importing TypeVar from typing_extensions rather than from typing, although I’m not sure if all type checkers support infer_variance at this point.

Some type checkers will warn you if there are methods/attributes that contradict your declared variance. mypy does it only for Protocol currently, but I think pyright warns in more places, assuming the corresponding checks are enabled.

Another way to get around variance in your example is to move the Union outside, i.e. G[A] | G[B] instead of G[A | B], this way, even if both the containers are invariant, you can still append either of the two G variants to the list, since they’re both a member of the union.

  1. i.e. it’s considered both a sub and superclass of any type ↩︎

  2. outside of __new__/__init__ or some classmethod ↩︎

  3. so you are only allowed to use it in return types and all the public attributes that store it need to be read-only ↩︎

1 Like