Skip to content

typevar variance keywords

basedpython adds in and out keywords on PEP 695 type parameters to declare variance directly at the declaration site:

class Source[out T]: ...
class Sink[in T]: ...
class Both[in out T]: ...

out T declares T covariant, in T contravariant, and in out T invariant. a bare T declares nothing: its variance is inferred from what the class body does with it, so it can come out covariant, contravariant, invariant or bivariant. writing a keyword is how you pin one down and stop the inference from following the body:

class Bare[T]:
    def get(self) -> T: ...        # inferred covariant

class Pinned[in out T]:
    def get(self) -> T: ...        # invariant, because it says so

Bare[int] is assignable to Bare[object]; Pinned[int] is not. variance affects subtyping in the obvious way:

  • Source[Dog] is assignable to Source[Animal] (covariant — T is produced)
  • Sink[Animal] is assignable to Sink[Dog] (contravariant — T is consumed)
  • Both[Dog] and Both[Animal] are assignable to neither (invariant)

the declaration has to match the usage

a keyword is a promise the rest of the program is checked against, so the class that makes it has to keep it. out T may only be produced, in T may only be consumed, and a mutable T-typed attribute does both — so it needs in out T:

class Bad[out T]:      # error: `T` is declared covariant, but `Bad` uses it contravariantly
    def set(self, value: T) -> None: ...

without the check the promise is a lie that nothing catches: Bad[int] would be assignable to Bad[object], and calling set("asdf") through that widened view would file a str away in int storage

in out T is invariance, which every usage satisfies, so it is never reported — and neither is a bare T, whose variance is inferred from the usage in the first place. a constructor is exempt: it produces the instance rather than writing through an existing one

two things a covariant class can do with a T in an input position: keep it private, which takes it off the observable surface altogether, or mark the position Overlapping[T], which asks only that the argument overlap T rather than be one

transpilation

on Python 3.12+ the keywords are stripped because PEP 695 itself does not yet support inline variance declarations:

class Source[T]: ...
class Sink[T]: ...
class Both[T]: ...

on pre-3.12 targets the keywords are passed through to the TypeVar polyfill, which emits the corresponding covariant=True / contravariant=True arguments:

_T = TypeVar("_T", covariant=True)
class Source(Generic[_T]): ...

_T_contra = TypeVar("_T_contra", contravariant=True)
class Sink(Generic[_T_contra]): ...

scope

variance keywords are recognized in two surface positions:

  1. on a PEP 695 type-parameter declaration (class C[out T]:), as shown above — affects the declared variance of T
  2. on a subscript argument (list[out int]), described below — affects only this one annotation without touching list's declaration

they are not allowed on bare TypeVar(...) calls (use the covariant= / contravariant= arguments directly there).

variance relates two specializations, so the declaration position has to be one that specializes: a class, or a generic type alias — which is exactly as variant as the type it expands to. a function's type parameter is solved afresh at every call and never specializes, and a type def is erased before anything could observe one, so a keyword there decides nothing and is reported (invalid-variance-declaration) rather than dropped:

def f[out T](t: T) -> None: ...   # error: a function's `T` has no variance

an alias's keyword is a claim about the expansion rather than a decision of its own, so it is checked against it — the same rule as a class's, reported at the same place:

type Alias[out T] = list[T]   # error: `T` is declared covariant, but the expansion is invariant

in out is invariance, which every expansion satisfies, and a parameter the expansion never mentions is never reported

use-site variance

writing Container[out T], Container[in T], or Container[in out T] gives an annotation a read-only, write-only, or read-write view over a generic container, without affecting the container's own declared variance:

def read(data: list[out int]):
    data[0]        # int
    data[0] = 1    # error — write rejected

def write(data: list[in int]):
    data[0] = 1    # ok — int accepted
    data[0]        # error — read rejected

def both(data: list[in out int]):
    data[0]        # int
    data[0] = 1    # ok

the container keeps its nominal identity — the projection rides along as a per-parameter tag, so list[out int] and set[out int] stay unrelated types. each argument of a multi-argument subscript is tagged independently, and an unmarked argument is simply untagged:

def f(m: dict[str, out int]):
    m["a"]         # int
    m["a"] = 1     # error — write rejected through the `out` view

def g(m: dict[out str, in int]):
    m["a"] = 1     # ok — int accepted through the `in` view
    m["a"]         # object — the value reads through the `in` view

a projection only ever adds to what the declaration allows. against a declared-invariant parameter, out relaxes the position to covariant and in to contravariant; against a parameter already declared out T or in T, the declared variance covers everything the projection could give and the projection is a no-op.

the projection is part of the type, so it also decides a parametric type testa is A[out int] matches covariantly even when A's T is invariant.

inlay hints

a type parameter that declares no variance gets its inferred one as an inlay hint, written where the keyword would go:

class Source[⟨out ⟩T]:
    def get(self) -> T: ...

class Sink[⟨in ⟩T]:
    def put(self, value: T) -> None: ...

a generic type alias is hinted the same way:

type Alias[⟨in out ⟩T] = list[T]

a parameter ty infers as bivariant is not hinted — basedpython has no spelling for it