2025, Nov 03 11:00

Python 3.12 variance inference: how to make generic classes truly read-only with private attributes

Learn how Python 3.12 variance inference treats public attributes as writable, and how private storage makes generics read-only and covariant for type checkers.

Variance inference in Python 3.12 changed how many of us think about generic classes. With explicit generic syntax, type checkers infer whether a type parameter is invariant, covariant, or contravariant based on how it is used. This is convenient—until a class that should clearly be read-only suddenly becomes invariant and refuses to pass type checks. Let’s walk through a minimal example, understand what’s going on, and see how to express true read-only semantics so that type checkers agree.

What the docs say

The introduction of explicit syntax for generic classes in Python 3.12 eliminates the need for variance to be specified for type parameters. Instead, type checkers will infer the variance of type parameters based on their usage within a class. Type parameters are inferred to be invariant, covariant, or contravariant depending on how they are used.

The surprising invariant: a minimal reproducible example

Consider a simple container that, conceptually, is read-only after instantiation. The intent is covariance: a container of a subtype should be usable where a container of a supertype is expected.

class DataBox[V]:
    # Intended to be covariant for read-only consumption
    def __init__(self, item: V) -> None:
        self.item = item
    def read(self) -> V:
        return self.item
def show_float(box: DataBox[float]) -> None:
    print(box)
num_box = DataBox(1)
# A type checker flags this as incompatible, because DataBox[int]
# is not treated as a subtype of DataBox[float].
show_float(num_box)

Even though this container looks read-only from the outside, a type checker infers the type parameter as invariant. At first glance it’s tempting to blame the constructor and assume that accepting V in __init__ is what forces invariance.

What actually causes invariance here

__init__ is not the culprit. The core issue is that the stored attribute is public. Because item is publicly set on the instance, type checkers treat it as writable by consumers of the class. In other words, the presence of a publicly writable attribute tied to the type parameter signals that the type can be mutated from outside, so the safe inference is invariance. The constructor merely passes the value through; the decisive part is that the attribute is exposed as public and thus considered to have a public “setter”.

The fix: make storage private to express read-only semantics

If the container is meant to be read-only, the storage should be private. Prefixing the attribute with an underscore communicates to type checkers that it is not part of the public interface. With that change, the same type parameter is inferred as covariant.

class DataBox[V]:
    def __init__(self, item: V) -> None:
        self._item = item
    def read(self) -> V: ...

With private storage, functions that require a specific instantiation now behave as expected under variance inference.

def needs_int_box(b: DataBox[int]) -> None: ...
needs_int_box(DataBox[object](object()))  # error
needs_int_box(DataBox[int](int()))        # fine
needs_int_box(DataBox[bool](bool()))      # fine

Here, a box of object is not accepted where a box of int is required, while a box of int or its compatible type works, aligning with covariant expectations in this design.

Why this matters for real-world code

When building libraries and APIs with Python 3.12’s explicit generics, public attributes tied to type parameters directly affect how type checkers infer variance. If your class is intended to be a read-only container, private storage is essential to communicate that intent. This small naming choice—using an underscore—can be the difference between ergonomic, type-safe APIs and confusing invariance that forces unnecessary casting or rejects valid uses.

A note that often trips people up: __init__ can be invoked multiple times on an instance; it’s __new__ that can’t be called twice because it returns a new instance. Also, avoid using int and float as a subtyping example; that special case is under discussion in the typing community.

Takeaways

Variance inference in Python 3.12 is powerful, but it’s purely based on how your class exposes and consumes its type parameter. If the class should be read-only, keep the internal storage private and provide only read accessors. Don’t attribute invariance to __init__; what matters to type checkers is whether the type parameter can be publicly written to. Designing APIs with this in mind leads to predictable behavior across type checkers such as Mypy and Pyright and helps your code communicate intent clearly.

The article is based on a question from StackOverflow by Leonardus Chen and an answer by InSync.