Python Type Hint: Contravariant, Covariant, Invariant

We all know the Liskov substitution principle. The type can be replaced by its subtype without breaking it. But how about the relationship of their generic types, C[subtype] and C[type]?

So, what is covariant?

If A<: B, you can replace B with A anywhere
We say C[T] where T bound to B is

  1. Contravariant, when A <: B => C[A] :> C[B]
    We can replace C[B] with C[A] anywhere.

  2. Covariant, when A <: B => C[A] <: C[B]
    We can replace C[A] with C[B] anywhere. But How to cause this situation? I will explain in the example code below.

  3. Invariant, If both circumstances do not suit. C[A] can not exchange with C[B], and vice versa.

Combine the concept with Sink / Source.

Accordingly to their behaviors, We have two kinds of objects defined.

  1. Source[T]: It produces T, is covariant to T.
  2. Sink[T]: It consumes T, is contravariant to T.

It is pretty abstract, so we directly move forward to the code below. Try to experiment by modifying according to comments.

import abc

from typing import Generic, TypeVar

class Base:
    def foo(self):
        print("foo")

class Derived(Base):
    def bar(self):
        print("bar")

First, we have Base and Derived. Derived is inherent from Base, so we have Derived <: Base.

T_co = TypeVar('T_co', bound='Base', covariant=True)

class Source(Generic[T_co]):
    @abc.abstractmethod
    def generate(self) -> T_co: # Produce T_co!
        pass

class SourceBase(Source[Base]):
    def generate(self) -> Derived: # Produce T_co!
        return Derived() 

class SourceDerived(Source[Derived]):
    def generate(self) -> Derived:
        return Derived() 

source: Source[Base] = SourceDerived()
source.generate()

#Try to uncomment lines below.
#source_derived: Source[Derived] = SourceBase()
#source_derived.generate()

Now, we have SourceDerived <: SourceBase. If we remove covariant=True, we will get this warning:

[Pyright reportGeneralTypeIssues] [E] Expression of type > "SourceDerived" cannot be assigned to declared type "Source[Base]"
  TypeVar "T_co@Source" is invariant
    "Derived" is incompatible with "Base"

If you modify covariant to contravariant, this is what happened. covariant tells the checker SourceDerived can be safely used anywhere we use SourceBase.

def generate(self) -> T_co: <- warining
        pass

warining: [Pyright reportGeneralTypeIssues] [E] Contravariant type variable cannot be used in return type
  TypeVar "T_co@Source" is contravariant

Look like covariant not only to check the place you use C[T_co] but also to check the method in C[T_co] return T_co.

Next, we take a look at the contravariant example.

T_contra = TypeVar('T_contra', bound='Base', contravariant=True)

class Sink(Generic[T_contra]):
    @abc.abstractmethod
    def consume(self, value: T_contra):
        pass

class SinkBase(Sink[Base]):
    def consume(self, value: Base):
        value.foo()

class SinkDerived(Sink[Derived]):
    def consume(self, value: Derived):
        value.bar()

    def other_func(self):
        pass

base = Base()
derived = Derived()
sink_derived: Sink[Derived] = SinkBase()
#we can safely consumer
sink_derived.consume(base)
sink_derived.consume(derived)
#Try to uncomment this line.
#sink_derived.other_func()

We have SinkDerive <: SinkBase here. Removing contravariant=True will get warning:

[Pyright reportGeneralTypeIssues] [E] Expression of type "SinkBase" cannot be assigned to declared type > "Sink[Derived]"
   TypeVar "T_contra@Sink" is invariant
    "Base" is incompatible with "Derived"

contravariant=True tells the static checker that the Base can be safely consumed Base or Derive type. Although we have annotated T_contra is contravariant, we will get an error if we call a method of Sink[Derived], sink_derived.other_func() for example, of course. Nevertheless, it is pretty common to assume that contravariant is opposite to covariant.

I think in most situations, we don't need to add contravariant or covariant right away. Only when the checker complains, do we look closely at the relationship of these generic types if used properly. If it is, we consider adding these hints, then.

21