What is the root for this difference between mypy and pyright?

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()

https://pyright-play.net/?code=GYJw9gtgBALgngBwJYDsDmUkQWEMoAqiApgGoCGIAUFQMYA25Azk1AIIBcUVUvUAJsWBQA%2BiNRIYYgBRNi9YABoo5LqhgBKDjz66AdAbqMWUAELb9hmgFkoAXkIkKIaQCJrr5QCMwAVxT8dmwAPmYauro0gsK0IMTkMMTSMFzwCMQA2tYAuuEAtAB8UNYWfEjCSCy%2BXgzMTMnKbFo6EVBxML4gKLDS5HYAjBotUPJypa3tnd0w0kNUcQBu8fQiaUmx8YnSphpDQA

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].

7 Likes

This typechecks under both:

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

Code sample in pyright playground

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.

1 Like

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.

@overload
def do(t: type[A]) -> A:...
@overload
def do(t: type[B]) -> B:...
def do(t: type[Any]) -> Any:
    if issubclass(t, A):
        return t(a=1)
    else:
        return t()

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.

Oh it turns out the last limitation can be circumvented:

@overload
def do[T: A](t: type[T]) -> T:...
@overload
def do[T: B](t: type[T]) -> T:...
def do(t: type[Any]) -> Any:
    if issubclass(t, A):
        return t(a=1)
    else:
        return t()
   

reveal_type(do(C)) # -> Revealed type is "__main__.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.