Python Typing (Part 1) - Covariance, Contravariance, Invariance

Today we will begin looking at more detail at typing in Python. Love it or hate it, (I like it), I think it can make some large codebases clearer and with fewer bugs.

The following intro part was actually in the FastAPI series when we were looking at our BaseCRUD types.

What? Ok so by default type variables are invariant. Invariant means that the variable type is neither covariant or invariant. Uff…short description here and then long article later? ok.

Well, this is the long article.

Imagine you have a tuple of Persons and a tuple of Programmers with types tuple[Person] and tuple[Programmer] respectively. We can then assign the tuple[Programmer] to a variable that expects tuple[Person] but not the other way around. For example:

class Person:
    ...

class Programmer(Person):
    ...

the_one_that_writes_the_code: Programmer = Programmer()
the_one_that_fixes_the_code: Programmer = Programmer()
my_friend: Person = Person()
your_friend: Person = Person()

friends: tuple[Person, ...] = (my_friend, your_friend)
backend_developers: tuple[Programmer, ...] = (the_one_that_writes_the_code, the_one_that_fixes_the_code)

None of this will raise an error. If you run mypy on it everything will be fine. We can have Persons friends and we can have programmers in backend_developers. What if we tried to assign backend_developers a tuple that contained both types? What if we tried to assign friends a tuple that contained both types? Let’s add

friends = (the_one_that_writes_the_code, my_friend)
backend_developers = (the_one_that_writes_the_code, my_friend)

What do you think mypy will say? “error: Incompatible types in assignment (expression has type “tuple[Programmer, Person]”, variable has type “tuple[Programmer, …]”)”. Why is that? Ok so assigning a tuple containing either Person or Programmer to friends was fine because all Programmers are also Persons (right?) whereas assigning a tuple containing both to backend_developers raised an error because not all Persons are Programmers. Shocking, I know.

So how do we formalise this using fancy computing words? Well, let’s introduce one more thing and that’s a Generic type. You know how we do list[int], or tuple[str] or dict[str, Any]? A generic type is a type that itself takes other types (like int, str, Any, etc.) in its parameters. The list, tuple and dict’s ancestors are all Generic types. Ok back to our Programmers and Persons. What do we mean when we talk about variance of generic types? To rephrase mypy on this topic, suppose we have Person and Programmer. Programmer is a subtype of Person.

  1. A generic class MyGeneric[T, …] is covariant in type variable T if MyGeneric[B, …] is always a subtype of MyGeneric[A, …]

In our example, the generic class MyGeneric[T, …] is covariant in type variable T if MyGeneric[Programmer, …] is always a subtype of MyGeneric[Person,…].

  1. A generic class MyGeneric[T, …] is contravariant in type variable T if MyGeneric[A, …] is always a subtype of MyGeneric[B, …]

Ok that’s what mypy says. What about what it means? A Callable is something that is covariant in its returns and contravariant in its arguments. Let’s look at both arguments and return types. Suppose we had:

def bring_me_a_person() -> Person:
    your_person: Person = Person()
    return your_person

def bring_me_a_programmer() -> Programmer:
    your_programmer: Programmer = Programmer()
    return your_programmer

and then

my_person: Person = bring_me_a_person()
my_person = bring_me_a_programmer()

No issues there. Why? Because my_person expects a Person and both programmers and persons are Person (i.e. the return type is covariant). The arguments, however, of a Callable are contravariant. Suppose we had:

# the type is Callable[[Person], None]
def walk(person: Person) -> None:
    print("A person is walking.")

# the type is Callable[[Programmer], None]
def programmer_walk(programmer: Programmer) -> None:
    print("A programmer is programming.")

def make_someone_walk(
    a_someone: Person,
    a_walking_function: Callable[[Person], None]
) -> None:
    a_walking_function(a_someone))

def make_programmer_walk(
    a_programmer: Programmer,
    a_programming_walk_function: Callable[[Programmer], None]
) -> None:
    a_programming_walk_function(a_programmer)

And then we tried to do:

vlad: Person = Person()
vladutmd: Programmer = Programmer()

make_someone_walk(who=vlad, walking_function=walk)
make_someone_walk(who=vladutmd, walking_function=walk)

Do you think that would cause any issues? Nope. Ok what about:

make_programmer_walk(who=vladutmd, walking_function=programmer_walk)
make_programmer_walk(who=vladutmd, walking_function=walk)

Still no. Interesting. The walking_function expected by the make_programmer_walk is of a Callable[[Programmer], None] type and we are passing in a Callable[[Person], None] type. This means that Callable[[Person], None] is a subtype of Callable[[Programmer], None]. And that is the definition of contravariance. To be more formal, as described by mypy, for two types A and B where B is a subtype of A:

A generic class MyContraGen[T, …] is called contravariant in type variable T if MyContraGen[A, …] is always a subtype of MyContraGen[B, …].

why did the above work? Well, a programmer can walk the way a person can. If we did the opposite, however:

make_someone_walk(who=vladutmd, walking_function=programmer_walk)

will raise an error.

error: Argument "walking_function" to "make_someone_walk" has incompatible type "Callable[[Programmer], None]"; expected "Callable[[Person], None]"

Why is that? Why can’t we send in a programmer_walk? Well, if we made a person do the programmer_walk, we’d be asking them to do something they cannot do. So for these purposes, Callable[[Programmer], None] is not a subtype of Callable[[Person], None]. Consequently, the Callable is not covariant in its argument types. What about contravariant? If it was contravariant, it would mean that [[Person], None] is a subtype of Callable[[Programmer], None].

Did make_programmer_walk(vlad_the_programmer, walk) raise an error? No. It expected a a_programmer_walk_function of the Callable[[Programmer], None] type. Since it accepted a Callable[[Person], None] type, the latter is a subtype of the former -> a function is contravariant in its arguments. But does it make sense for it to accept a Callable[[Person], None] type? Well a Programmer will be able to do what the Person can do, so if we send a function that the Person can do, the Programer will be able to do as well.

Finally, what about invariance? After all, this is why we started this aside….Actually, it was initially an aside but then became an article by itself, oops. So a type is invariant if it’s neither variant nor covariant. So for two types A and B, where B is a subtype of A, if GenericClass[B, …] is not a subtype of GenericClass[A, …] and GenericClass[A, …] is not a subtype of GenericClass[B, …]. So which generic classes are invariant? Basically,

Most mutable containers are invariant.

Why’s that? Well, consider the following:

my_friends: list[Programmer] = []
my_coworkers: list[Person] = []

bob: Person = Person()
charlie: Person = Person()
daniel: Programmer = Programmer()

def connect_to_friend(friends: list[Person], friend: Person) -> None:
    friends.append(friend)

def connect_to_coworker(coworkers: list[Programmer], coworker: Programmer) -> None:
    coworkers.append(coworker)

connect_to_friend(my_friends, bob)
connect_to_coworker(my_coworkers, daniel)

If we mypy it, we’d get:

error: Argument 1 to "connect_to_friend" has incompatible type "list[Programmer]"; expected "list[Person]"
note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance
note: Consider using "Sequence" instead, which is covariant
error: Argument 1 to "connect_to_coworker" has incompatible type "list[Person]"; expected "list[Programmer]"

Ok so listlProgrammer] is not a subtype of list[Person] (i.e. it is no covariant) and list[Person] is not a subtype of list[Programmer] (i.e. it is not covariant). Why? Because lists are mutable. For example, imagine Programmer had a method called “program”. If we did my_friends[0].program() and we had a Person in there, it would fail. Since we can append an incompatible object, this makes lists invariants.

I think we will stop here today, after a short intro on covariance. I hope you have an idea of what covariance, contravariance and invariance is. In the next one, we’ll have a closer look at some of the different types of typing there can be: nominal, structural, duck typing, etc.