mypy fails to detect conditional

Viewed 318

A working example of the issue (Python 3.8):

from typing import Union

class TestA:
    def test(self) -> str:
        return "testa"


class TestB:
    def test(self) -> str:
        return "testb"


tests = Union[TestA, TestB]
more = Union[TestA, TestB, str]


def test_pass(p: tests) -> str:
    return p.test() # No error


def test_fail(p: more) -> str:
    if type(p) in [TestA, TestB]:
        return p.test() # Item "str" of "Union[TestA, TestB, str]" has no attribute "test"
    else:
        return ""

print(test_fail(TestA()))  # "testa"
print(test_fail(TestB()))  # "testb"
print(test_fail("str"))  # "str"

I have this exact scenario showing up in a few places in my codebase and it's really bothering me I have to disable type checking on these lines. Is this something mypy is expected to miss or am I the one in error here? The error being returned seems unreasonable since a str could never make it to that leg of the conditional.

4 Answers

mypy cannot use the runtime value of an expression like type(p) in [TestA, TestB] to determine that p is not a str. You might use typing.overload instead to provide two different type signatures for type checking: one for tests, the other for str.

from typing import overload


@overload
def test_fail(p: tests) -> str:
    ...

@overload
def test_fail(p: str) -> str:
    ...

def test_fail(p):
    if type(p) in [TestA, TestB]:
        return p.test()
    else:
        return ""

(I am not sure, exactly, how mypy makes use of these annotations to identify the type(p) in [TestA, TestB] case with tests and the else with str. This also doesn't seem to work with mypy --strict, as it will complain about test_fail not being annotated. The lack of type hints on the actual implementation is intentional and necessary.)

Or, you can simply use typing.cast to tell mypy that you know the (more) specific type of p:

from typing import cast


def test_fail(p: more) -> str:
    if type(p) in [TestA, TestB]:
        return cast(tests, p).test() # Item "str" of "Union[TestA, TestB, str]" has no attribute "test"
    else:
        return ""

If your goal is to exclude a single case, mypy will also recognize is not. But it can't recognize more complex expressions like in.

if type(p) is not str:
    ...

You can use a TypeGuard like this:

tests = Union[TestA, TestB]

def is_tests(x) -> TypeGuard[tests]:
    return type(x) in (TestA, TestB)

and then:

if is_tests(p):
    return p.test()
Related