mypy icon indicating copy to clipboard operation
mypy copied to clipboard

And of isinstances / typeguards containing unions

Open Anonymous-Stranger opened this issue 3 years ago • 2 comments

Bug Report

When taking the and of multiple isinstances/typeguards, it seems like only the most recent is used to constrain the variable whose type is being checked.

To Reproduce

See Gist: https://gist.github.com/a4cc96c4bb9207a3ae6a344016d2e46d

from typing import Any, TypeGuard, reveal_type

def int_or_str(x: Any) -> TypeGuard[int | str]:
    return isinstance(x, int | str)

def int_or_list(x: Any) -> TypeGuard[int | list]:
    return isinstance(x, int | list)

def working_in_pyright(x: Any) -> TypeGuard[int]:
    if isinstance(x, int | str) and isinstance(x, int | list):
        reveal_type(x)  # pyright: int, mypy: int | list
        return True
    return False

def failing_in_both(x: Any) -> TypeGuard[int]:
    if int_or_str(x) and int_or_list(x):
        reveal_type(x)  # int | list
        return True
    return False

# incidentally, changing `and` to `or` results in the correct output `int | str | list`

Expected Behavior

I think the code reveal_type(x) should return int in each of these examples.

I'm not sure about the failing_in_both example: I vaguely understand that user-defined type guards do not constrain the negative case of an if (but I haven't yet understood the rationale) -- does similar reasoning explain why the type guards on the LHS and RHS of the and do not combine?

I mean, technically, I could write a TypeGuard that modifies the argument, and so I guess the LHS of the and could cease to hold when the RHS is called; is this why? Would @erictraut's StrictTypeGuard https://github.com/python/typing/discussions/1013 be able to resolve this?

Actual Behavior

main.py:14: note: Revealed type is "Union[builtins.int, builtins.str, builtins.list[Any]]"
main.py:21: note: Revealed type is "Union[builtins.int, builtins.str, builtins.list[Any]]"
Success: no issues found in 1 source file

Your Environment

  • Mypy version used: 0.991
  • Python version used: 3.11

Anonymous-Stranger avatar Jan 12 '23 07:01 Anonymous-Stranger

Mypy appears to be implementing TypeGuard support correctly here. In failing_in_both, the revealed type should be int | list because that's what the int_or_list type guard function specifies.

The working_in_pyright function doesn't use TypeGuard but rather uses built-in isinstance type narrowing logic. Narrowing for isinstance is extremely complex and not standardized, so it's likely that there will be some differences between type checkers here. And indeed, as you show, pyright and mypy produce slightly different results in this particular case. I don't consider that a bug in either type checker, just an implementation difference.

Pyright contains provisional support for the StrictTypeGuard proposal if you want to try it out. It does produce the results you're looking for if you switch the type of x from Any to object. (In the current provisional StrictTypeGuard implementation, pyright does not attempt to narrow Any.)

erictraut avatar Jan 12 '23 19:01 erictraut

I see, thanks for the explanation! In that case, would it be possible to relabel this issue from a bug report to a feature request? In the case of isinstance type narrowing, while int | list is still a valid revealed type in the working_in_pyright function, it'd be nice to have the narrower revealed type of int.

Anonymous-Stranger avatar Jan 13 '23 00:01 Anonymous-Stranger