Search code examples
pythontype-inferencemypyisinstance

MyPy does not recognise x[idx] as boolean after isinstance(x[idx], bool)


Core issue
MyPy is not able to identify / validate the return value x[idx] as being of boolean type.

if isinstance(x[idx], bool) is True:
        return x[idx]

Complete example code

from typing import Union, Optional

x: dict[str, Union[bool, str]] = {'a': True, 'b': 'foo'}


def return_if_bool(idx: str) -> Optional[bool]:
    if isinstance(x[idx], bool):
        return x[idx]
    return None


print(return_if_bool('a'))


MyPy returns
8: error: Incompatible return value type (got "bool | str", expected "bool | None") [return-value]


Workaround
What works is using typing.cast:

return typing.cast(bool, x[idx])

A “cleaner”(imo) workaround avoiding cast was pointed out by @Wombatz – assign y = x[idx] as in:

    y: Union[bool, str] = x[idx]
    if isinstance(y, bool):
        return y

Why?
I would expect this to work without having to resort to typing.cast() or assigning to a variable y = x[idx], but couldn’t fully figure out why it doesn’t, especially as it looks incredibly trivial.


Possible explanations
@Wombatz input that MyPy cannot know that x[idx] will return the same value on repeated access makes sense and gets very close to fully answering this question. However, MyPy seems to be quite capable of recognising behaviour as exhibited by e.g. iterators, so its still somewhat odd.


Solution

  • You are accessing the dictionary twice, but you only check the result once. Mypy cannot know that repeated access to the same key will always return the same type (and it should not, because a dict sub class could do something weird).

    So in short: only access the dict once, check that type and return the value.

    value = x[idx]
    if isinstance(value, bool):
        return value
    return None
    

    The narrowing of the name value is persistent, but the narrowing of an arbitrary expression is not.

    This also holds for attribute access or other function calls.

    So, if you want to type check something for mypy and it "forgets" the check, always try to save the expression in a variable.

    I believe this is a duplicate, but it's impossible to find on a mobile device

    Appendix: here is an object that would violate the typing rules, if mypy didnt "forget" about the instance check

    class MyDict(dict[str, bool | str]):
        def __getitem__(self, key: str) -> str | bool:
            if randrange(2) == 1:
                return "hi"
            return True
    

    Of course this is a silly implementation, but the more general case - where a sub class could return different variants of a union for identical method calls - is treated in the same way by mypy.

    So to answer that part of your question: mypy apparently just does not have a specialization for the type narrowing when accessing dicts, even when it can be proven that the dict is not a subclass.