mypy icon indicating copy to clipboard operation
mypy copied to clipboard

Incorrect type narrowing on union of `TypedDict`

Open Dreamsorcerer opened this issue 3 years ago • 5 comments

from typing import Any, TypedDict

class _SessionData(TypedDict):
    created: int
    session: dict[str, Any]


class _EmptyDict(TypedDict):
    """Empty dict for typing."""


SessionData = _SessionData | _EmptyDict

data: SessionData

reveal_type(data)
a = data if data else None
reveal_type(a)
aiohttp_session/__init__.py:56: note: Revealed type is "Union[TypedDict('aiohttp_session._SessionData', {'created': builtins.int, 'session': builtins.dict[builtins.str, Any]}), TypedDict('aiohttp_session._EmptyDict', {})]"
aiohttp_session/__init__.py:58: note: Revealed type is "Union[TypedDict('aiohttp_session._EmptyDict', {}), None]"

I also get the same result if I make it not data. The _SessionData type just disappears for no reason and it keeps saying the empty dict will be the result regardless of the boolean check.

Dreamsorcerer avatar Jan 29 '22 18:01 Dreamsorcerer

I'm adding mypy to a project just now and it left me wanting a bit around nested TypedDicts. and I think my issue is connected to this one. In describing highly nested objects (e.g. REST API responses), it is often useful to define the levels as TypedDicts in some of which the values might be unions of two other TypedDicts. E.g. like this:

from typing import TypedDict, Union

class InnerA(TypedDict):
    common_key: str

class InnerB(TypedDict):
    common_key: str
    extra_key: int

class Outer(TypedDict):
    id: int
    inner: list[Union[InnerA, InnerB]]

my_outer: Outer = {
    'id': 1,
    'inner': [{
      'common_key': 'which type could it be?',
      'extra_key': 2
    }]
}

This trips up mypy, which then complains that Extra key "extra_key" for TypedDict "InnerA".

korompaiistvan avatar Jun 14 '22 14:06 korompaiistvan

This trips up mypy, which then complains that Extra key "extra_key" for TypedDict "InnerA".

Maybe you have a usecase in your code that actually requires it, but that example could just use a single TypedDict with an optional extra_key.

Dreamsorcerer avatar Jun 14 '22 18:06 Dreamsorcerer

from typing import Union, TypedDict, cast


class DictA(TypedDict):
    a: str


class DictAAndB(TypedDict):
    a: str
    b: str


class DictC(TypedDict):
    c: str


def func(foo: Union[DictA, DictAAndB, DictC, None]) -> None:
    reveal_type(foo)
    # Mypy:
    #     Union[
    #         TypedDict('test_mypy.DictA', {'a': builtins.str}),
    #         TypedDict('test_mypy.DictAAndB', {'a': builtins.str, 'b': builtins.str}),
    #         TypedDict('test_mypy.DictC', {'c': builtins.str}),
    #         None,
    #     ]
    # Pyright:
    #     DictA | DictAAndB | DictC | None
    #
    # Expected.

    assert foo is not None

    reveal_type(foo)
    # Mypy:
    #     Union[
    #         TypedDict('test_mypy.DictA', {'a': builtins.str}),
    #         TypedDict('test_mypy.DictC', {'c': builtins.str}),
    #     ]
    # Pyright:
    #     DictA | DictAAndB | DictC
    #
    # Unexpected behavior from Mypy only. What happened to DictAAndB?

    foo = cast(Union[DictA, DictAAndB, DictC], foo)
    reveal_type(foo)
    # Mypy:
    #     Union[
    #         TypedDict('test_mypy.DictA', {'a': builtins.str}),
    #         TypedDict('test_mypy.DictC', {'c': builtins.str}),
    #     ]
    # Pyright:
    #     DictA | DictAAndB | DictC
    #
    # Is there any workaround?

I think this is caused by the same underlying behavior as the OP. Mypy 1.11.1. Pyright narrows the types in exactly the way I expect.

mfisher87 avatar Aug 16 '24 14:08 mfisher87

Continuing to try to work around this; it seems I can't even cast my way out. I updated my example in the previous comment.

mfisher87 avatar Aug 16 '24 17:08 mfisher87

I ended up switching to Pyright for this project because I couldn't find any way to override Mypy's incorrect narrowing in this situation.

mfisher87 avatar Aug 25 '24 16:08 mfisher87