2025, Sep 27 17:00

Typing any generic in Python 3.13: use top materialization (object, Never, Any) instead of extra type parameters

Learn how to type functions that accept any Foo[T] using top materialization: choose object, Never, or Any by variance and avoid generics in basedpyright.

Typing a method that accepts any instance of a generic class often looks deceptively simple until variance gets involved. In Python 3.13, using the new generic syntax, the straightforward idea “take any Foo[T], I don’t care which T” quickly collides with type-checker expectations, especially in tools like basedpyright. The question is how to express this intention without spamming Any or over-parameterizing the function signature.

Problem setup

Assume a generic class where the type parameter is not needed in a particular function. The class itself is simple; the friction appears at the call boundary.

class Crate[U]: ...
def handle_crate_a(x: Crate) -> None: ...
from typing import Any
def handle_crate_b(x: Crate[Any]) -> None: ...
def handle_crate_c[U](x: Crate[U]) -> None: ...

The first attempt omits brackets entirely and triggers an error in basedpyright. The second imports Any, which leads to a warning that many developers try to suppress either inline or globally, but that defeats the purpose of the warning. The third duplicates type parameters and, if there’s an upper bound on U, forces repeating that bound in the function signature, increasing coupling and noise for a function that doesn’t actually use U.

What’s really going on

The most general fully static type for a generic class is determined by the variance of its type parameters. This kind of type is called the top materialization (also referred to as upper bound materialization). The idea is to pick a specific argument for the type parameter that represents “the widest safe type,” but the choice depends on whether the parameter is covariant, contravariant, bivariant, or invariant.

When the parameter is covariant, the top materialization is Crate[object], or Crate[UpperBound] if there is an upper bound. When the parameter is contravariant, the top materialization is Crate[Never]. When the parameter is invariant, there isn’t a denotable top materialization at the moment; in practice, the best available option is Crate[Any].

The fix in code

Use top materialization that matches the variance of the generic parameter. If the parameter is covariant or bivariant, annotate with object or the upper bound. If it is contravariant, annotate with Never. If it is invariant, fall back to Any.

from typing import Any, Never
# Covariant or bivariant generic parameter
def handle_crate(x: Crate[object]) -> None: ...
# Contravariant generic parameter
def handle_crate(x: Crate[Never]) -> None: ...
# Invariant generic parameter (currently no better static option)
def handle_crate(x: Crate[Any]) -> None: ...

If there’s an upper bound on the type parameter and the parameter is covariant, annotate with that bound instead of object. This communicates exactly what “any Crate” means under the constraint while remaining fully static.

Why this matters

Picking the correct top materialization avoids brittle signatures and unnecessary generics in places where the type parameter isn’t used. It also sidesteps the temptation to rely on Any. As many have found out the hard way, Any effectively disables type-checking for that path; it may feel expedient initially, but it can suppress meaningful diagnostics that you expect your checker to catch later. Basedpyright’s reportExplicitAny exists precisely to make that trade-off visible.

An aside: seeing it resolved in practice

Some tooling automates the resolution of top materializations. The ty resolver is one example.

class BothWays[V]: ...
class GoesOut[T]:
    def show(self) -> T: ...
class GoesIn[T]:
    def add(self, _: T) -> None: ...
class Strict[T]:
    def mix(self, _: T) -> T: ...
from typing import Any
from ty_extensions import Top
def probe(
    a: Top[BothWays[Any]],
    b: Top[GoesOut[Any]],
    c: Top[GoesIn[Any]],
    d: Top[Strict[Any]]
):
    reveal_type(a)      # BothWays[object]
    reveal_type(b)      # GoesOut[object]
    reveal_type(c)      # GoesIn[Never]
    reveal_type(d)      # Top[Strict[Any]]

Takeaways and guidance

When you want a function to accept any instance of a generic class without caring about its concrete type parameter, rely on top materialization. For a covariant or bivariant parameter, annotate with object or the declared upper bound. For a contravariant parameter, annotate with Never. For an invariant parameter, use Any because there is no denotable top materialization at present. This approach keeps signatures honest and static, avoids redundant generic parameters, and preserves the intent that the function works with any valid instantiation of the class.

If you consider switching a parameter to covariant to enable object or an upper bound in the annotation, think carefully about whether the API truly satisfies covariance. If it does, the result is both expressive and future-proof. If not, prefer the correct variance plus the corresponding top materialization, even if that means living with Any for invariants.

The article is based on a question from StackOverflow by Frank William Hammond and an answer by InSync.