Variance: why list[int] isn't list[float]

typing · Expert · 13 min

Variance explains the most puzzling checker errors and is essential for designing generic APIs.

If int is acceptable where float is expected, is list[int] acceptable where list[float] is expected? No, and the reason is variance.

A function given list[float] may append 3.5. If the caller passed a list[int], its list now contains a float: type safety broken. Mutable containers are therefore invariant: list[int] and list[float] are unrelated.

Read-only containers are safe: Sequence[int] is a Sequence[float], since nothing can be inserted. They are covariant: the subtype relation goes the same direction.

Things that consume values are contravariant: a Callable[[float], None] can be used where Callable[[int], None] is expected, because a function that handles any float certainly handles ints. Parameters are contravariant, return types covariant.

For your own generics:

  • With the 3.12 syntax (class Box[T]), variance is inferred from how T is used: only in return positions means covariant, only in parameters contravariant, both (or a mutable attribute) invariant.
  • With old-style TypeVars you declare it: TypeVar("T_co", covariant=True).

Practical fix for the common error: accept Sequence[float] or Iterable[float] instead of list[float] in parameters you don't mutate.

Example

from collections.abc import Callable, Sequence

def mean(xs: Sequence[float]) -> float:      # covariant: list[int] is accepted
    return sum(xs) / len(xs)

def add_half(xs: list[float]) -> None:       # invariant: needs exactly list[float]
    xs.append(0.5)

class Reader[T]:                             # T only returned -> inferred covariant
    def __init__(self, value: T) -> None:
        self._value = value
    def get(self) -> T:
        return self._value

def read_number(r: Reader[float]) -> float:
    return r.get()

ints: list[int] = [1, 2, 3]
print(mean(ints), read_number(Reader(4)))   # 2.0 4
handler: Callable[[int], object] = lambda x: float(x)   # contravariant parameter
# add_half(ints) -> checker error: list[int] is not list[float] (list is invariant)

Remember

  • Mutable containers are invariant; read-only ones are covariant
  • Parameters are contravariant, return types covariant
  • Accept Sequence/Iterable instead of list to avoid invariance errors

Watch out for

  • Typing a read-only parameter as list[float] and then fighting errors when callers pass list[int].

Check yourself

  1. Why is list[int] not accepted where list[float] is expected?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/typing.html#typing.TypeVar