2024-07-10

Covariance for the practically inclined

mypy is happy with this:

a = tuple[str | int, ...]()
b = tuple[int, ...]()
a = b

But not with this:

a = list[str | int]()
b = list[int]()
a = b

It gives the error:

Incompatible types in assignment (expression has type list[int], variable has type list[str | int])

Why?

The really short theoretical answer

The T in list[T] is invariant, T in tuple[T, ...] is covariant.


Huh?


The short practical answer

.append(...)

If we have the following function:

def sum_list(l: list[int]) -> int: ...

Imagine if this type-checked:

a = list[str | int]()
b = list[int]()
a = b
a.append("foo")
sum_list(b)

We'll clearly get an error when we sum_list(b), as b contains a str.

Note the equivalent isn't true for immutable things like tuples:

a = tuple[str | int, ...]()
b = tuple[int, ...]()
a = b
a += ("foo", )
sum_tuple(b)

The same applies when we write our own classes, the following raises a mypy error:

T = TypeVar("T")

class MyList(Generic[T]):
    ...

a = MyList[str | int]()
b = MyList[int]()
a = b

Whereas the following is valid:

T_co = TypeVar("T_co", covariant=True)

class MyList(Generic[T_co]):
    ...

a = MyList[str | int]()
b = MyList[int]()
a = b

What happens if we try make MyList mutable like a real list though:

T_co = TypeVar("T_co", covariant=True)

class MyList(Generic[T_co]):
    internal: tuple[T_co, ...]

    def append(self, v: T_co) -> None:
        self.internal += (v, )

We get different error for def append(...):

Cannot use a covariant type variable as a parameter

This is a good thing, we can't introduce the same bug we made earlier!

If we want an immutable MyList method, we can just use an invariant T in the method:

class MyList(Generic[T_co]):
    internal: tuple[T_co, ...]

    def append(self, v: T) -> MyList[T]:
        return MyList(self.internal + (v, ))

Contravariance

But why can't we use a covariant type variable as a parameter?

Consider the following functions:

def f_int(x: int) -> int: ...
def f_int_or_str(x: int | str) -> int: ...

And the following types:

FInt      = Callable[[int], int]
FIntOrStr = Callable[[int | str], int]

Now let's typecheck the following:

a: FInt      = f_int        # f_int(4)            makes sense
b: FInt      = f_int_or_str # f_int_or_str(4)     makes sense
c: FIntOrStr = f_int        # f_int("foo")        makes no sense!
d: FIntOrStr = f_int_or_str # f_int_or_str("foo") makes sense

We indeed get an error from mypy for c, whoop!

How is this achieved in Callable's definition?

Well, let's write our own class Schmallable, firstly with an invariant T:

class Schmallable(Generic[T]):
    ...

FInt = Schmallable[int]
FIntOrStr = Schmallable[int | str]
f_int: FInt
f_int_or_str: FIntOrStr

a: FInt      = f_int
b: FInt      = f_int_or_str
c: FIntOrStr = f_int
d: FIntOrStr = f_int_or_str

We get an error for b and c - this isn't what we wanted!

Lets try again with a covariant T:

class Schmallable(Generic[T_co]):
    ...

Now we get an error for b - still not we wanted :-(

Finally, let's try using a contravariant T:

T_con = TypeVar("T_con", contravariant=True)

class Schmallable(Generic[T_con]):
    ...

We get an error for c only - just the type variable we wanted!

Slightly more formal definitions

Here's the definitions of covariant and contravariant from the mypy docs (where X < Y means "X is a subtype of Y"):

Given B < A, for C[T]

if C[B] < C[A], T is covariant
if C[A] < C[B], T is contravariant
else T is invariant

Or slightly more concretely:

Given B = int, A = int | str, for C[T]

if C[int] < C[int | str], T is covariant
if C[int | str] < C[int], T is contravariant
else T is invariant

Or even more concretely, with tuple, Callable and list:

tuple[int] < tuple[int | str, ...],
    therefore T in tuple[T, ...] is covariant
Callable[[int | str], ...] < Callable[[int], ...],
    therefore Ts in Callable[[T, T', ...], ...] are contravariant
list[int] !< list[int | str, ...] and list[int | str] !< list[int],
    therefore T in list[T] is invariant