I’m nearing readiness to start working on finding a typechecker to work with on a reference implementation + a PEP proposal.
I’d like to check with the community to make sure the behavior of 2 specific cases is something they don’t have issue with before pushing through the final stretch.
I can’t guarantee I won’t just put the objection in a “rejected alternatives” section, but I’d at least like to be aware of any objections and consider them properly in case there is reasonable room to do better.
I’d like to make a change to assignability and subtyping semantics.
Right now, the type system does not differentiate between Never, as a result of logical exhaustive negation (ie, case match or isinstance chaining) and Never, as a non-return effect from a function.
This difference doesn’t generally matter for most subtypes, but if intersections where to be user expressible, one can imagine cases where intersections shouldn’t be used due to incompatible return types of methods, yet could be “satisfied” by just making the method always error instead.
This is a “hole” in the expressibility of the type system.
There are two ways to handle this that will prevent issues.
-
Methods of subtypes must be capable of returning a value if the super type’s corresponding method can return a value.
-
Recommend a warning for typecheckers to implement that is not mandatory when detecting such subtypes, as well as intersections that provoke the need for such subtypes.
There is also the option to “do nothing” for now, and just trust library authors not to create such situations for their users.
While I personally lean toward option 1 here as it paves the way to better handling of Never, and potential future extensions of the type system toward supporting typing effects, I’d be fine with the second option.
The “do nothing” option is one I will reject for any initial proposal here unless this is an absolute deal breaker for typecheckers or important libraries
Examples
class A:
def foo(self, x: int) -> int: ...
class B:
def foo(self, x: int) -> str: ...
class C:
def foo(self, x: int) -> int: ...
class D:
def foo(self, x: str) -> str: ...
def should_error_or_warn(x: A & B): ...
# A.foo is (int) -> int, B.foo is (int) -> str,
# there is no definition of foo for A & B that satisfies both without removing
# the ability of the method to return a value.
# this is asking a caller to create a suspiciously defined subtype
def allowed(x: C & D): ...
# Because C.foo and D.foo's parameters are disjoint, the disjoint return types can be mapped soundly
I’d like to make a change to isinstance semantics.
Right now, isinstance is treated as a “pseudo-intersection”. I don’t think this is the right long-term option.
I’m fine if the answer to this is “This isn’t a big enough issue” or “this is a pre-existing problem that can be solved separately”, but if there’s enough appetite to make the change at the same time, I believe it is an appropriate improvement to make as an associated change at the same time, and I am willing to ensure that the reference implementations provided have this change as well.
Proposed change:
Instead of treating the type being checked as having the default generic (ie list in isinstance(x, list) being list[Any]), the semantics should be understood to only narrow on the outer type, given the actual behavior of isinstance at runtime.
eg:
def foo(x: Sequence[int] | int):
if isinstance(x, Sequence):
reveal_type(x)
# `Sequence[int] | Sequence & int`, not `(Sequence[int] | int) & Sequence[Any]`
Here only the outer type has been checked. Treating this as an intersection would actually widen the type, rather than narrow it, which is both counter-intuitive and not in line with the behavior at runtime.
This is effectively a limited user expression of existential types. I am not proposing a generalized user expression of this, General expressibility can be done separately in the future as a way to improve expressibility of things like TypeIs functions.
Some type checkers may already effectively be behaving in this way, but I’d like the language to be in the specification if possible so that users do not expect intersection behavior from isinstance