Skip to content

Typing in the context of dynamic languages 2: Variance in Python

In my previous article, we saw why and how to express types in Python for static analysis. We also briefly illustrated the reasons for using the most precise types possible to help the type checker. In the following parts, we will focus on other types of subtyping that are not based on unions or class hierarchy.

You can check out the previous article here, but now let’s get started with generic types.

Generic types are a form of subtyping with its own intricacies. A generic type can be thought of as a function on types: it takes a type as an argument to create a new type. Here is an example to clarify the concept:

l: list[int] = [1, 2]

The list type is a generic type: it takes an argument (int) to create a new type (“a list of integers”). We usually note type parameters with a T (for “type” of course), so we would talk about the generic type list[T].
There can of course be multiple type parameters, such as one type for keys and one for values in dictionaries: dict[K, V]:

d: dict[str, int] = {'foo': 42}

And we can also define custom generic types: 

from typing import Generic, TypeVar
 
T = TypeVar('T')
 
def generic_function(v: T) -> T:
    return v
 
class GenericClass(Generic[T]):
    # we can use the type T for both class attributes
    # and methods
 
    y: T
 
    def __init__(self, x: T):
        self.x = x

This example only shows the syntax without giving a real usable class, but we will address this in the next sections.

Invariance

Variance is an important property of generic types that a lot of developers may not be aware of. It describes how generic types behave with regard to subtyping. We will use a list as an example.

The question here is whether a list[int | str] is or is not a subtype of list[int], and vice versa. Let’s explore this with code.

l: list[int | str] = [1, 'foo']
 
# with an int list we can get the first element and add 1 
# safely
l.get(0, 0) + 1
# does not type because we might get a string so
# list[int | str] is not a subtype of list[int]
 
l: list[int] = [1, 2]
# with a list[int | str] we could append a string
l.append("foo")
# this does not type so list[int] is not a subtype
# of list[int | str]

So as we can see, even though int is a subtype of int | str, there is no subtyping relationship between list[int] and list[int | str]. In this case, we say that list is invariant in its type parameter.

This is important when it comes to using precise types: in good, precise typing, you cannot replace the type parameter of a list with a subtype or a supertype. It also means that if you want flexibility, you might want to consider a more “flexible” generic type, such as Sequence.

Covariance

Indeed, the Sequence type is what we call covariant in its type parameter, meaning that, for instance Sequence[int] is a subtype of Sequence[int | str]. If we can iterate on a list of integers or strings, we can safely iterate on just integers. It is very intuitive, but here is an example for good measure:

from collections.abc import Sequence
 
def f(s: Sequence[int | str]) -> None:
    for v in s:
        print(v*2)
 
f([1, 2, 3])  # all good !

We can define our own covariant types by specifying it in the TypeVar:

from collections.abc import Sequence
from typing import Generic, TypeVar
 
T = TypeVar('T', covariant=True)
 
class Grower(Generic[T]):
 
    def __init__(self, v: T):
        self.v = v
        self.t: list[T] = []
 
    def get(self) -> Sequence[T]:
        self.t = [self.v, *self.t]
        return self.t
 
def f(g: Grower[int | str]) -> None:
    for _ in range(5):
        for v in g.get():
            print(v*2, end=',')
        print()
 
f(Grower(1))  # all good !

As a rule of thumb, your generic will be covariant in a type parameter if that parameter appears only in covariant positions, except for __init__. One important covariant position is the return type of functions and methods. Therefore:

def f() -> int: ...
# has a more precise type than
def f() -> Number: ...

Contravariance

When the type parameter appears, for instance, in the parameters of a function, the opposite actually happens. This is called contravariance. Contravariant types are less common than covariant types. Roughly, all covariant types are derivatives of having a type parameter in function argument, or at least the type parameter is consumed rather than produced. So let’s take a deeper look at this function example.

Let’s go back to our previous example of the next function:

basenumber = int | float
i: int = 1
 
def next(n: int) -> basenumber:
    return n + 1
 
next(i)

The int type is a subtype of basenumber. Contravariance means that, unlike in covariance, using basenumber instead of int in the type parameter will result in a type Callable[[basenumber], basenumber] that is a subtype of Callable[[int], basenumber].

basenumber = int | float
i: int = 1
 
def next(n: basenumber) -> basenumber:
    return n + 1
 
# this is still valid of course, meaning that we can use
# Callable[[basenumber], basenumber] wherever we can have
# a Callable[[int], basenumber], meaning it is a subtype
next(i)

So to summarise this example, we had a type Callable[[int], basenumber]. Thanks to covariance, we have a first subtype Callable[[int], int], and thanks to contravariance we have a second subtype Callable[[basenumber], basenumber].

Those two subtypes cannot be compared to one another. They are both just as precise, and the choice of which one to use depends on your intention, but both are better options than the original.

In my next article, we will focus on defining custom types, so stay tuned!

Search