Sorry, I still don’t understand what type variance is

12 minute read

I enjoy static typing and use it at work and for personal projects. It makes my life easier, but sometimes the errors that my type checker produces confuse me. For example, I have heard of type covariance and contravariance, but I still can’t quite follow when and why should I care about that?

In this post we discuss a couple of realistic scenarios that lead us to the ideas of type variance.

Before we start…

… lets talk about the technical setup. The code in this post is written in python3.11, and we are using mypy==1.1.1 as a static type checker. You can create a virtual environment by running the following commands:

> python3.11 -m venv variance-py311
> source variance-py311/bin/activate
> python -m pip install mypy==1.1.1

To type check a python file with a name filename.py, you can run

> mypy --strict filename.py

The errors that we show in the examples below are mypy errors, not runtime errors. This type checker points to the potential problems that will almost certainly cause problems at run time of our program.

Our little helpers

To make our discussion easier, we define a couple of entities that we will use to illustrate the concepts. First, we have a Human class that represents all humans:

class Human:
    pass

Second, we have two interfaces (or traits) that describe someone who likes science (LikesScience), and someone who is an expert in the nervous system (BrainExpert).

class LikesScience:
    def talk_about_science(self) -> None:
        pass

class BrainExpert:
    def explain_brain_activity(self) -> None:
        pass

(Note that neither LikesScience nor BrainExpert are related to the Human. For example, a program can explain brain activity.)

Third, let’s define a subclass of Human that represents scientists,

class Scientist(Human, LikesScience):
    pass

and a subclass of Scientist that represents neuroscientists:

class NeuroScientist(Scientist, BrainExpert):
    pass

By definition, all NeuroScientists are Scientists, and all Scientists are Humans. We can confirm that with

assert isinstance(NeuroScientist(), Scientist)
assert isinstance(Scientist(), Human)

# or
assert issubclass(NeuroScientist, Scientist)
assert issubclass(Scientist, Human)

Types and subtypes

Let’s clarify what “type” and “subtype” mean.

In a programming language, a type is a description of

  • a set of values, and
  • a set of allowed operations on those values (ref).

The NeuroScientist is a type that allows two operations on its values, talk_about_science and explain_brain_activity:

neuroscientist = NeuroScientist()

neuroscientist.talk_about_science()
neuroscientist.explain_brain_activity()

Imagine we have something that uses a Scientist object, for example a function that interviews a scientist:

def interview(scientist: Scientist) -> None:
    scientist.talk_about_science()

We constructed the NeuroScientist class as a subclass of the Scientist. Is it true that we can interview a NeuroScientist? Certainly yes, because all NeuroScientists are Scientists, and in this example they can talk about science.

We can interview both a Scientist and a NeuroScientist (i.e., a scientist that happen to be a neuroscientist):

scientist = Scientist()
interview(scientist)

neuroscientist = NeuroScientist()
interview(neuroscientist)

This means that the NeuroScientist type is a subtype of the Scientist type. Any NeuroScientist can substitute a Scientist, meaning that whatever can use a Scientist will be just as happy to use a NeuroScientist (see a more accurate discussion here)

Naturally, we can’t interview a Human - because not all Humans are Scientists, and Humans in general cannot talk about science. Just a Human object does not allow the talk_about_science operation, and this is not allowed:

human = Human()
interview(human)  # Argument 1 to "interview" has incompatible type "Human"; expected "Scientist"

And therefore Human is not a subtype of Scientist (but, as you can imagine, Scientist is a subtype of Human).

Covariance: substituting more than a Scientist

Just as we can substitute a simple type Scientist with its subtype NeuroScientist, can substitution work for more complex types that depend on either Scientist or NeuroScientist? And if yes, how does that work?

In other words, how does subtyping work for complex types? And what is a complex type after all?

Imagine we have conference where many scientists can meet and chat. It does not matter how many scientists attend a conference, all of them will talk about science:

def attend_conference(scientists: Sequence[Scientist]) -> None:
    for scientist in scientists:
        scientist.talk_about_science()

The scientists is a Sequence of Scientist. The fact that scientists is a Sequence means that the scientists can be counted, we can confirm if a particular Scientist is in the sequence, we can iterate over its elements, and a few other actions on the sequence are allowed. We will talk about what Sequence is later, but we can say that Sequence[Scientist] is a complex type (as opposed to the simple Scientist type) which is built on top of other types.

While a Sequence of Scientist can attend a conference, i.e., this is fine:

scientists = [Scientist(), Scientist(), Scientist()]  # Sequence[Scientist]
attend_conference(scientists)

can a Sequence of NeuroScientist attend a conference? Yes, because any NeuroScientist is a Scientist and can talk about science, so this is also fine:

neuro_scientists = [NeuroScientist(), NeuroScientist()]  # Sequence[NeuroScientist]
attend_conference(neuro_scientists)

This shows that Sequence[NeuroScientist] can substitute Sequence[Scientist], and therefore Sequence[NeuroScientist] is a subtype of Sequence[Scientist]. The direction of the type relationship ScientistNeuroScientist is preserved when we wrap the types into a Sequence: Sequence[Scientist]Sequence[NeuroScientist].

The fact that the “type” to “subtype” direction remains the same when we talk about a Sequence cannot be taken for granted, as we will see later. It is a property of the Sequence that it preserves this direction, and it is called covariance: if we have two types, A and B, and B is a subtype of A, then Sequence[A] is a subtype of Sequence[B].

There are other types that behave similarly (covariantly), for example Union. There are also types that are not covariant. Let’s talk about those.

Contravariance: substituting functions

We created the interview function above. mypy allows to inspect the type of an object using reveal_type:

reveal_type(interview)
# N: Revealed type is "def (scientist: Scientist)"

The interview is a Callable that accepts a single Scientist input argument and returns nothing (None). The interview, just like anything else, has its own type, and this type a complex type because it is built using other types.

We can explicitly define this type:

from typing import Callable

IT = Callable[[Scientist], None]  # IT == Interview Type

and the type of interview is IT.

Let’s prepare a use case to illustrate the “substitute a function” scenario. Imagine we have a NeuroScientist friend, and we would like to organize a meeting with him. For the meeting to be useful, we need to figure out some activity we can run with a NeuroScientist. For example, let’s define an activity that is “ask a question about a brain night activity”:

def ask_brain_activity_question(neuroscientist: NeuroScientist) -> None:
    neuroscientist.explain_brain_activity()

Similarly to how we inferred and explicitly defined the type of the interview function, we can do the same for ask_brain_activity_question:

AQT = Callable[[NeuroScientist], None]  # AQT = Ask Question Type

The type of ask_brain_activity_question is AQT.

Now we can create a meeting! For a meeting, we will choose an activity of type AQT, and run that activity with our NeuroScientist friend:

def meeting(activity: AQT) -> None:
    neuroscientist_friend = NeuroScientist()
    activity(neuroscientist_friend)

We can run a meeting with the ask_brain_activity_question as an activity, because meeting expects an AQT type of activity, and ask_brain_activity_question is of the AQT type:

meeting(ask_brain_activity_question)

But wait, since our neuroscientist_friend is also a Scientist, can we run a meeting with interview as an activity?

meeting(interview)

Certainly yes, because interview can run with any Scientist.

What just happened is that we substituted something of type AQT with something of type IT. This makes IT a subtype of AQT. Apparently, contrary to the NeuroScientist being a subtype of Scientist, the Callable[[Scientist], None] is a subtype of Callable[[NeuroScientist], None].

(Note, that if the meeting specified activity: IT as its input, we won’t be able to use something of type AQT as an activity. This is because an AQT activity can be an activity that only a NeuroScientist can follow, while the meeting stated that the activity must be valid for any Scientist. This means that Callable is not covariant in its arguments.)

This inversion of subtyping is a property of the Callable type, and is called contravariance. This property is not specific to our example: if we have two types, A and B, and B is a subtype of A, then Callable[[A], ...] is a subtype of Callable[[B], ...].

To sum up, Callable is contravariant in its arguments.

Sometimes substitution is not possible

There are covariant types, like Sequence. There are contravariant types, like Callable in its arguments. And there are types that neither co- nor contravariant.

Imagine we have a scientific outreach event. We invite Scientists to the event so that they can inspire new people about what they do by talking about science. We will consider our outreach event to be successful if someone suddenly realized that she likes science, and can talk about science herself! That would make someone who was not a Scientist before, a Scientist after the event.

In this example, we have a group of Scientist that can change during the event, as new Scientists might appear. A MutableSequence is a Sequence that can change (the Sequence does not allow operations that change it), and this is exactly what we want.

We define an outreach event as:

def outreach_event(scientists: MutableSequence[Scientist]) -> None:
    for scientist in scientists:
        scientist.talk_about_science()
    new_scientist = Scientist()
    scientists.append(new_scientist)

All scientists talk, and in the end a new scientist appears and becomes a part of the scientists group.

As we can expect, running such event with Scientists should be fine:

scientists = [Scientist(), Scientist(), Scientist()]
outreach_event(scientists)

Running such event with a MutableSequence[Human] is wrong: a Human cannot talk about science!

humans = [Human(), Human()]
outreach_event(humans)
# error: Argument 1 to "outreach_event" has incompatible type "MutableSequence[Human]";
# expected "MutableSequence[Scientist]"  [arg-type]

We can conclude that MutableSequence is not contravariant, as we cannot substitute MutableSequence[Scientist] with MutableSequence[Human].

Interestingly, running this event with MutableSequence[NeuroScientist] is also a bad idea. As we said, someone becomes a Scientist during the event, but attending the event is not enough to acquire a brain expert certification to become a NeuroScientist. So we may end up with a Scientist being added to the existing group of NeuroScientists - and all of a sudden that group stops being a group of NeuroScientists, as not all of its members are brain experts.

This is therefore wrong:

neuro_scientists: MutableSequence[NeuroScientist] = [NeuroScientist(), NeuroScientist()]
outreach_event(neuro_scientists)
# error: Argument 1 to "outreach_event" has incompatible type "MutableSequence[NeuroScientist]";
# expected "MutableSequence[Scientist]"  [arg-type]

We can conclude that MutableSequence is not covariant, as we cannot substitute MutableSequence[Scientist] with MutableSequence[NeuroScientist].

MutableSequence is an example of a type that is neither covariant nor contravariant. Such types are called invariant.

How generic can this be?

Sequence, Callable, MutableSequence, and many more… Those types only make sense if they are of something. For example, a sequence of scientists - Sequence[Scientist], or a mutable sequence of humans - MutableSequence[Human]. Otherwise, what is a Sequence, a sequence of what? We can’t work with a Sequence of unspecified things.

These complex types that require a simple type (or multiple simple types) to be bound to are called generic types.

We can create our own generic types using the Generic as a base:

from typing import Generic, TypeVar


T = TypeVar("T")


class Journal(Generic[T]):
    pass

In this example, Journal is a generic type with one parameter (just like Sequence). We must somehow specify how many parameters a generic type has, and this is how we do that. To create the parameters we use the TypeVar.

Creating a object of a generic type is slightly different to how we create objects of non-generic types. For example, this does not make sense because we don’t specify a Journal of what we just created:

journal = Journal()
# error: Need type annotation for "journal"  [var-annotated]

On the other hand, explicitly specifying the value for the parameter type makes it work:

journal = Journal[Scientist]()

Now we know that a journal is a Journal that belongs to a Scientist.

We said that complex types, such as Journal, can be covariant, contravariant, or invariant, and that it is a property of the type. How can we control this property for the type we just created, Journal?

Being co-, contra-, or invariant can be controlled via the type parameters. This makes more sense if we consider generic types with multiple parameters, such as Mapping. Mapping is a collection of (key, value) pairs, and we need two parameters that describe this generic type. In general, the generic class can behave differently with respect to its parameters. Mapping is invariant on key (only specific type is allowed as keys) and covariant on value (it is allowed to store subtypes as values).

How can a type parameter control the variance? When defining a type parameter, TypeVar accepts covariant=True or covariant=False (default), and contravariant=True or contravariant=False (default) as arguments. For example, T = TypeVar("T") creates an invariant type argument, because both covariant and contravariant are False by default. To create a covariant T_co or a contravariant T_contra type arguments, we do:

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

The Journal type defined above is invariant, because its type argument T was defined as invariant. To create a covariant version of a Journal, JournalCo, and a contravariant version JournalContra, we write:

class JournalCo(Generic[T_co]):
    pass


class JournalContra(Generic[T_contra]):
    pass

The Sequence type that we used before is actually defined something like class Sequence(Generic[T_co]): ... (covariant because of T_co), and MutableSequence is defined something like class MutableSequence(Generic[T]): ... (invariant because of T).

As we can imagine, all variance rules that we discussed before should apply to the Journals. We cannot substitute anything for an invariant Journal:

def read_invariant(journal: Journal[Scientist]) -> None:
    pass

human_journal = Journal[Human]()
scientist_journal = Journal[Scientist]()
neuroscientist_journal = Journal[NeuroScientist]()

read_invariant(human_journal)  # error: Argument 1 to "read_invariant" has incompatible type "Journal[Human]"; expected "Journal[Scientist]"  [arg-type]
read_invariant(scientist_journal)  # OK
read_invariant(neuroscientist_journal)  # error: Argument 1 to "read_invariant" has incompatible type "Journal[NeuroScientist]"; expected "Journal[Scientist]"  [arg-type]

We can substitute a subtype for a covariant JournalCo:

def read_covariant(journal: JournalCo[Scientist]) -> None:
    pass

human_journal_co = JournalCo[Human]()
scientist_journal_co = JournalCo[Scientist]()
neuroscientist_journal_co = JournalCo[NeuroScientist]()

read_covariant(human_journal_co)  #  error: Argument 1 to "read_covariant" has incompatible type "JournalCo[Human]"; expected "JournalCo[Scientist]"  [arg-type]

read_covariant(scientist_journal_co)  # OK
read_covariant(neuroscientist_journal_co)  # OK

And we can substitute a supertype for contravariant JournalContra:

def read_contravariant(journal: JournalContra[Scientist]) -> None:
    pass

human_journal_contra = JournalContra[Human]()
scientist_journal_contra = JournalContra[Scientist]()
neuroscientist_journal_contra = JournalContra[NeuroScientist]()

read_contravariant(human_journal_contra)  # OK
read_contravariant(scientist_journal_contra)  # OK
read_contravariant(neuroscientist_journal_contra)  # error: Argument 1 to "read_contravariant" has incompatible type "JournalContra[NeuroScientist]"; expected "JournalContra[Scientist]"  [arg-type]

Summary

Types and subtypes are related behaviourally: it is type safe to substitute a type with a subtype, and expect that a program will behave correctly.

Complex (generic) types are built on top of simple types, and can behave differently than the types they were built with. This is called variance, and it refers to how subtyping between more complex types relates to subtyping between their components (ref). Covariant means that the subtyping relation of the simple types are preserved for the complex types. Contravariant means the subtyping relation of the simple types is reversed for the complex types (ref).

Standard and custom generic classes are defined using type variables as parameters. Covariance or contravariance is not a property of a type variable, but a property of a generic class defined using this variable (ref).

Updated: