This topic grew out of a ty issue, where it was argued that the current spec implies that tuple[Any]
is assignable to Never
. There was some discussion about whether this is true for tuples, but that discussion is orthogonal to the issue I’m raising here. The same problem appears less arguably with TypedDicts.
Consider two typeddicts:
from typing import Any, TypedDict
class TD1(TypedDict):
a: int
b: Any
class TD2(TypedDict):
c: str
d: Any
To check whether TD1
is assignable to TD2
(or vice versa), we check whether there is any pair of materializations of both such that one is a subtype of the other. And even though the two TypedDicts have different sets
of keys, there is such a pair of materializations: the materializations where the two instances of Any
are substituted with Never
. In both cases, a required key becomes of type Never
. But a TypedDict must be an instance of dict (not a subclass) with those exact keys, and there cannot be a dict with a key b
and no value. Therefore, TD1
with b
materialized to Never
and TD2
with d
materialized to Never
are both empty types, i.e., Never
.
This means that strictly speaking, type checkers should accept code like this:
x: list[TD1] = list[TD2]()
y: TD1 = TD2()
This is obviously undesirable: these types appear clearly distinct, and users won’t expect an inner Any to expand out like this. Indeed, current type checkers won’t actually allow this.
Other places where this problem can arguably appear include:
- Fixed-length tuples like
tuple[Any]
, though there is an argument that because of the existence of tuple subclasses,tuple[Never]
is an inhabited type and not equivalent to Never. - Classes with an attribute like
x: Any
, though here there’s room for discussion around whether attributes need to be necessarily set, and around descriptors. - Intersections like
A & Any
andB & Any
become consistent with each other even if A and B are nonoverlapping types, because both can materialize to Never. But of course intersections are not a standardized part of the current type system.
I believe this is a problem because actual type checker behavior diverges from the rules implied by the spec, and the behavior of the type checkers is clearly the more useful choice. To make the theoretical framework in the spec useful, we have to change something in the spec.
Suggested solutions
(1) Guillaume Duboc via @carljm (link): Modifying assignability
But they also use an additional restriction on assignability (beyond just “consistent subtyping”). Any type which can materialize to Never is a consistent subtype of any other type which can materialize to Never, per the definition of consistent subtyping. But assignability is meant to establish that an inhabitant of one type can substitute for an inhabitant of another type – this is not meaningful in the absence of inhabitants. So for A to be assignable to B, they require both that A is a consistent subtype of B, and that, if the bottom or lower bound materialization of A is Never, that the upper bound or top materializations of A and B be non-disjoint. Another way to describe this is that there must be some object that can inhabit some possible materialization of both A and B, in order for A to be assignable to B.
This refers to the implementation of gradual typing in Elixir. Guillaume also shared a more formal definition of this relationship, which adds back the property that Never is a subtype of itself.
I think this is a promising approach but the formulation has a few problems:
- The “top” and “bottom” materializations of a type are interesting concepts, but they’re not currently part of the spec for the Python type system, and there are technical reasons why the definitions used in Elixir may not work well in Python. I think in the future we may come up with a formalization that adds these concepts to Python, but I don’t think a suitable formalization exists yet.
- The definition would lead to some undesirable results. Consider whether, given a
class SubList[T](list[T]): pass
,list[Any]
is assignable toSubList[Any]
. The bottom materialization ofSubList[Any]
isNever
(since the class is invariant, so its specializations do not have any other common subtype), andNever
is a subtype of some (in fact, all) materializations oflist[Any]
, soSubList[Any]
is a consistent subtype oflist[Any]
. Andlist[Any]
andSubList[Any]
are not disjoint types, since instances of saySubList[int]()
are members of both the typeslist[int]
andSubList[int]
(becauseSubList
is a subclass oflist
). Therefore, we find thatlist[Any]
is assignable toSubList[Any]
.
I would propose the following formulation instead:
Given two gradual types A and B, A is assignable to B if either (a) both A and B are equivalent to Never or (b) there exists a pair of fully static materializations A’ and B’ of A and B such that A’ is a subtype of B’ and A’ is not equivalent to Never.
Corollaries:
Any
is not assignable toNever
. This would be the main user-visible change to type checker behavior resulting from this definition, but I think it’s a positive change: assignability toNever
mostly comes up in the context ofassert_never()
, and if you use that, you probably don’t want it to succeed if the argument is of typeAny
.- However, this does not translate to “deeper” levels of types:
Sequence[Any]
is still assignable toSequence[Never]
. That is becauseSequence[Never]
is a possible materialization ofSequence[Any]
, and it is an inhabited type (it contains e.g. the empty tuple). - Assignability is no longer a synonym of consistent subtyping, as consistent subtyping lacks the special provision for Never.
(2) @mikeshardmind (link): Different understanding of Never
“Consistent subtyping requires that if the super type is inhabited, that the subtype can be as well” (not is, but can be. it doesnt require a proof that some type can exist, only that there is no knowledge that prohibits such a type from existing)
This requires a slight change in the understanding of Never, from a consistent subtype of all types, to a representation of the absence of the possibility of a value consistent with the available type information (which happens to be more accurate)
I may not fully understand this but I think it comes down to saying that Never is not a subtype of other inhabited types, only of itself. @mikeshardmind gives an example where Callable[[], Never]
is no longer a subtype of Callable[[], int]
. I think this would break a significant amount of code and make the type system less consistent. For example, a function may take an onerror
callback typed as Callable[[something], object]
; it should be allowed to pass a Callable[[something], Never]
to let errors bubble up.
(3) My suggestion (link): No materialization to Never
A gradual type that is not Never cannot materialize to Never, or to any type equivalent to Never.
This means that a type like TypedDict[{"x": Any}]
cannot materialize to TypedDict[{"x": Never}]
(because that type is equivalent to Never
), but a type like Callable[[], Any]
can materialize to Callable[[], Never]
(because this is an inhabited type).
I think this leads to similar results as (1) but creates a bigger hole in the foundational concepts of the type system, because Never
in general is a meaningful type, and the definition of materialization is more useful when it remains included.
(4) Another suggestion: Distinct Nevers
This is another new idea (I hinted at it here): what if we just pretend that when Never comes up out of tuple[Never]
, it’s a different type than the Never that comes out of TypedDict[{"x": Never}]
, even though both types contain the same (empty) set of runtime values. I think this approach has some promise as an implementation technique within a type checker, but it’s very much inconsistent with a set-theoretic type system: after all, the empty set is the empty set. I list it here in case there is some way I missed to turn this idea into something that makes sense.
Recommendation
I believe the best fix is a modified definition of assignability (suggestion 1). Unless a better solution comes up, I’ll prepare a PR to the spec to make that change.