2024-07-10
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])
The T
in list[T]
is invariant, T
in tuple[T, ...]
is covariant.
Huh?
.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 tuple
s:
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, ))
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!
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