The following snippet fails for mypy, but passes for pyright.
Using constraints instead of a bound solves the issue, but I wonder who is right here.
Also, what should I be using if my goal is to check for exhaustiveness with assert_never?
from typing import TypeVar
class A:
def __init__(self, a: int):
...
class B:
...
M = TypeVar("M", bound=A| B)
def create(t: type[M]) -> M:
if issubclass(t, A):
return t(a=1)
else:
return t()
This limitation is due to the way mypy performs type narrowing for issubclass and isinstance. In this example, mypy narrows t to type[A]. You can see this if you add a reveal_type(t) within the if block. This means t(a=1) evaluates to type A, and A is not assignable to the return type M.
Pyright narrows the type of t to type[A]*. The asterisk denotes a conditional type. This is a concept I invented to handle cases like this. It’s effectively an intersection type, so we could also write it as type[A] & type[M] or type[A & M]. This means t(a=1) evaluates to type A & M, which is assignable to the return type M.
As for which is “correct”, we need to consider both the typing spec and type theory. The typing spec is silent when it comes to type narrowing behaviors, so from that perspective, both type checkers are “correct” and are conforming with the typing spec.
From the perspective of type theory, pyright is correct because t is know to initially be type type[M], and the type guard check guarantees that it’s also type[A]. Mypy is narrowing t to type[A] and “forgetting” that it’s also type[M].
class A:
def __init__(self, a: int):
...
class B:
...
M = A | B
def create(t: type[M]) -> M:
if issubclass(t, A):
return t(a=1)
else:
return t()
class C(A):
pass
create(C)
By removing the type variable your option type checks, but loses type information, which is usually detrimental when accepting multiple possible types and returning a construction of it
from typing import reveal_type
class A:
def __init__(self, a: int):
...
class B:
...
M = A | B
def create(t: type[M]) -> M:
if issubclass(t, A):
return t(a=1)
else:
return t()
def intended[T: A | B](t: type[T]) -> T:
if issubclass(t, A):
return t(a=1)
else:
return t()
class C(A):
pass
reveal_type(create(C)) # Type of "create(C)" is "A | B"
reveal_type(intended(C)) # Type of "intended(C)" is "C"
There’s not much to be done here other than for affected users to take it up with mypy, as mypy’s behavior is at odds with user intent and theory here.
Thank you @erictraut for the detailed explanation and the pointer.
I am still somewhat confused as to why the error says “A is not assignable to return type M”? After all, isn’t M defined to be an instance of a subtype of A or B?
In case anyone wants to do something like this in this in mypy, you need to explicitly list all cases with overloads.
This works, although it quickly becomes very verbose and intimidating to the average typing-user.
Another caveat is that it wont work in combination with assert_never.
Finally, the type inference will be less precise if you actually provide a subclass of A as input.
class C(A): ...
reveal_type(do(C)) # --> A
whereas in the original approach you would infer a C.
For all mypy knows M, the type variable, could be just B. It lost the information about the relation of t and M (see first explanation from Eric as to why). Then the return type is M=B and then you obviously can’t return an instance of type A
Yes, a subtype of A or B, but we don’t necessarily know which one.