Repository: python/mypy

Issue:
Unsoundness in subclassing Mappings with TypedDicts
In the course of implementing TypedDicts into Pyre, I came across an issue with how mypy is treating the relationship between TypedDicts and Mappings. 
It seems like the implemented semantics are to treat a `T1 = TypedDict[ {k_1: v_1, k_2: v:2, ... k_n: v:n} ]` as a subclass of `T2 = Mapping[str, Union[v_1, v_2, ... v_n]]`.  However, this causes a problem when combined with the structural subtyping rule that `T3 = TypedDict[ {k_1: v_1, k_2: v:2, ... k_n: v:n, k': v'} ]` is a subclass of `T1`, which implies that `T3` is a subclass of `T2`.  When you call `__getitem__` on a `T2` that's actually a `T3` with key `k'`, you would unexpectedly get a `v'`.  This can lead to an undetected potential AttributeError as in the following example:

```python
from typing import Mapping
from mypy_extensions import TypedDict

class Y():
    def ya(self)-> int:
        return 7

class Z():
    def za(self) -> int:
        return 8

Foo = TypedDict('Foo', {'visible': Y, 'hidden': Z})

Bar = TypedDict('Bar', {'visible': Y})

def access(x:Mapping[str, Y]) -> None:
    if "hidden" in x:
        x["hidden"].ya()

def hide(x:Bar) -> None:
    access(x)

def f(x: Foo) -> None:
    hide(x)

td: Foo = {'visible': Y(), 'hidden': Z()}
f(td)
```

This passes mypy typechecking but when run gives an attribute error.

I think we're going to end up having TypedDicts just subclass `Mapping[str, Any]` for this reason.


Discussion:
Yeah, we are aware of the problem -- and I actually started working on a fix earlier today :-). I'm trying out what happens if make all typed dicts have `Mapping[str, object]` as a supertype. This would be safer, but it may require users to add some extra casts.

Resolving patch:
diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py
--- a/mypy/semanal_typeddict.py
+++ b/mypy/semanal_typeddict.py
@@ -283,16 +283,6 @@ def build_typeddict_typeinfo(self, name: str, items: List[str],
         info = self.api.basic_new_typeinfo(name, fallback)
         info.typeddict_type = TypedDictType(OrderedDict(zip(items, types)), required_keys,
                                             fallback)
-
-        def patch() -> None:
-            # Calculate the correct value type for the fallback Mapping.
-            assert info.typeddict_type, "TypedDict type deleted before calling the patch"
-            fallback.args[1] = join.join_type_list(list(info.typeddict_type.items.values()))
-
-        # We can't calculate the complete fallback type until after semantic
-        # analysis, since otherwise MROs might be incomplete. Postpone a callback
-        # function that patches the fallback.
-        self.api.schedule_patch(PRIORITY_FALLBACKS, patch)
         return info
 
     # Helpers


Test patch:
diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test
--- a/test-data/unit/check-classes.test
+++ b/test-data/unit/check-classes.test
@@ -4514,7 +4514,9 @@ class Bar(TypedDict):
 
 def foo(node: NodeType) -> int:
     x = node
-    return x['x']
+    # TODO: This is incorrect (https://github.com/python/mypy/issues/5930), but ensure that it
+    #       doesn't crash at least
+    return x['x']  # E: Incompatible return value type (got "object", expected "int")
 [builtins fixtures/isinstancelist.pyi]
 [out]
 
diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test
--- a/test-data/unit/check-typeddict.test
+++ b/test-data/unit/check-typeddict.test
@@ -6,7 +6,7 @@ Point = TypedDict('Point', {'x': int, 'y': int})
 p = Point(x=42, y=1337)
 reveal_type(p)  # E: Revealed type is 'TypedDict('__main__.Point', {'x': builtins.int, 'y': builtins.int})'
 # Use values() to check fallback value type.
-reveal_type(p.values()) # E: Revealed type is 'typing.Iterable[builtins.int*]'
+reveal_type(p.values()) # E: Revealed type is 'typing.Iterable[builtins.object*]'
 [builtins fixtures/dict.pyi]
 [typing fixtures/typing-full.pyi]
 
@@ -16,7 +16,7 @@ Point = TypedDict('Point', {'x': int, 'y': int})
 p = Point(dict(x=42, y=1337))
 reveal_type(p)  # E: Revealed type is 'TypedDict('__main__.Point', {'x': builtins.int, 'y': builtins.int})'
 # Use values() to check fallback value type.
-reveal_type(p.values()) # E: Revealed type is 'typing.Iterable[builtins.int*]'
+reveal_type(p.values()) # E: Revealed type is 'typing.Iterable[builtins.object*]'
 [builtins fixtures/dict.pyi]
 [typing fixtures/typing-full.pyi]
 
@@ -26,7 +26,7 @@ Point = TypedDict('Point', {'x': int, 'y': int})
 p = Point({'x': 42, 'y': 1337})
 reveal_type(p)  # E: Revealed type is 'TypedDict('__main__.Point', {'x': builtins.int, 'y': builtins.int})'
 # Use values() to check fallback value type.
-reveal_type(p.values()) # E: Revealed type is 'typing.Iterable[builtins.int*]'
+reveal_type(p.values()) # E: Revealed type is 'typing.Iterable[builtins.object*]'
 [builtins fixtures/dict.pyi]
 [typing fixtures/typing-full.pyi]
 
@@ -36,7 +36,7 @@ from mypy_extensions import TypedDict
 EmptyDict = TypedDict('EmptyDict', {})
 p = EmptyDict()
 reveal_type(p)  # E: Revealed type is 'TypedDict('__main__.EmptyDict', {})'
-reveal_type(p.values()) # E: Revealed type is 'typing.Iterable[<nothing>]'
+reveal_type(p.values()) # E: Revealed type is 'typing.Iterable[builtins.object*]'
 [builtins fixtures/dict.pyi]
 [typing fixtures/typing-full.pyi]
 
@@ -286,16 +286,16 @@ def widen(p: Point) -> Point3D:
 from mypy_extensions import TypedDict
 from typing import Mapping
 Point = TypedDict('Point', {'x': int, 'y': int})
-def as_mapping(p: Point) -> Mapping[str, int]:
+def as_mapping(p: Point) -> Mapping[str, object]:
     return p
 [builtins fixtures/dict.pyi]
 
-[case testCannotConvertTypedDictToCompatibleMapping]
+[case testCannotConvertTypedDictToIncompatibleMapping]
 from mypy_extensions import TypedDict
 from typing import Mapping
 Point = TypedDict('Point', {'x': int, 'y': int})
-def as_mapping(p: Point) -> Mapping[str, str]:
-    return p  # E: Incompatible return value type (got "Point", expected "Mapping[str, str]")
+def as_mapping(p: Point) -> Mapping[str, int]:
+    return p  # E: Incompatible return value type (got "Point", expected "Mapping[str, int]")
 [builtins fixtures/dict.pyi]
 
 [case testTypedDictAcceptsIntForFloatDuckTypes]
@@ -342,8 +342,8 @@ from typing import Dict, MutableMapping
 Point = TypedDict('Point', {'x': int, 'y': int})
 def as_dict(p: Point) -> Dict[str, int]:
     return p  # E: Incompatible return value type (got "Point", expected "Dict[str, int]")
-def as_mutable_mapping(p: Point) -> MutableMapping[str, int]:
-    return p  # E: Incompatible return value type (got "Point", expected "MutableMapping[str, int]")
+def as_mutable_mapping(p: Point) -> MutableMapping[str, object]:
+    return p  # E: Incompatible return value type (got "Point", expected "MutableMapping[str, object]")
 [builtins fixtures/dict.pyi]
 [typing fixtures/typing-full.pyi]
 
@@ -377,25 +377,29 @@ f(ll) # E: Argument 1 to "f" has incompatible type "List[TypedDict({'x': int, 'z
 from typing_extensions import Protocol
 from mypy_extensions import TypedDict
 
+class StrObjectMap(Protocol):
+    def __getitem__(self, key: str) -> object: ...
 class StrIntMap(Protocol):
     def __getitem__(self, key: str) -> int: ...
 
 A = TypedDict('A', {'x': int, 'y': int})
 B = TypedDict('B', {'x': int, 'y': str})
 
-def fun(arg: StrIntMap) -> None: ...
+def fun(arg: StrObjectMap) -> None: ...
+def fun2(arg: StrIntMap) -> None: ...
 a: A
 b: B
 fun(a)
-fun(b)  # Error
+fun(b)
+fun2(a) # Error
 [builtins fixtures/dict.pyi]
 [out]
-main:14: error: Argument 1 to "fun" has incompatible type "B"; expected "StrIntMap"
-main:14: note: Following member(s) of "B" have conflicts:
-main:14: note:     Expected:
-main:14: note:         def __getitem__(self, str) -> int
-main:14: note:     Got:
-main:14: note:         def __getitem__(self, str) -> object
+main:18: error: Argument 1 to "fun2" has incompatible type "A"; expected "StrIntMap"
+main:18: note: Following member(s) of "A" have conflicts:
+main:18: note:     Expected:
+main:18: note:         def __getitem__(self, str) -> int
+main:18: note:     Got:
+main:18: note:         def __getitem__(self, str) -> object
 
 [case testTypedDictWithSimpleProtocolInference]
 from typing_extensions import Protocol
@@ -415,7 +419,7 @@ def fun(arg: StrMap[T]) -> T:
     return arg['whatever']
 a: A
 b: B
-reveal_type(fun(a))  # E: Revealed type is 'builtins.int*'
+reveal_type(fun(a))  # E: Revealed type is 'builtins.object*'
 reveal_type(fun(b))  # E: Revealed type is 'builtins.object*'
 [builtins fixtures/dict.pyi]
 [out]
@@ -430,7 +434,7 @@ p1 = TaggedPoint(type='2d', x=0, y=0)
 p2 = Point3D(x=1, y=1, z=1)
 joined_points = [p1, p2][0]
 reveal_type(p1.values())   # E: Revealed type is 'typing.Iterable[builtins.object*]'
-reveal_type(p2.values())   # E: Revealed type is 'typing.Iterable[builtins.int*]'
+reveal_type(p2.values())   # E: Revealed type is 'typing.Iterable[builtins.object*]'
 reveal_type(joined_points)  # E: Revealed type is 'TypedDict({'x': builtins.int, 'y': builtins.int}, fallback=typing.Mapping[builtins.str, builtins.int])'
 [builtins fixtures/dict.pyi]
 [typing fixtures/typing-full.pyi]
@@ -467,8 +471,8 @@ left = Cell(value=42)
 right = {'score': 999}  # type: Mapping[str, int]
 joined1 = [left, right]
 joined2 = [right, left]
-reveal_type(joined1)  # E: Revealed type is 'builtins.list[typing.Mapping*[builtins.str, builtins.int]]'
-reveal_type(joined2)  # E: Revealed type is 'builtins.list[typing.Mapping*[builtins.str, builtins.int]]'
+reveal_type(joined1)  # E: Revealed type is 'builtins.list[typing.Mapping*[builtins.str, builtins.object]]'
+reveal_type(joined2)  # E: Revealed type is 'builtins.list[typing.Mapping*[builtins.str, builtins.object]]'
 [builtins fixtures/dict.pyi]
 
 [case testJoinOfTypedDictWithCompatibleMappingSupertypeIsSupertype]
@@ -484,18 +488,6 @@ reveal_type(joined2)  # E: Revealed type is 'builtins.list[typing.Sized*]'
 [builtins fixtures/dict.pyi]
 [typing fixtures/typing-full.pyi]
 
-[case testJoinOfTypedDictWithIncompatibleMappingIsObject]
-from mypy_extensions import TypedDict
-from typing import Mapping
-Cell = TypedDict('Cell', {'value': int})
-left = Cell(value=42)
-right = {'score': 'zero'}  # type: Mapping[str, str]
-joined1 = [left, right]
-joined2 = [right, left]
-reveal_type(joined1)  # E: Revealed type is 'builtins.list[builtins.object*]'
-reveal_type(joined2)  # E: Revealed type is 'builtins.list[builtins.object*]'
-[builtins fixtures/dict.pyi]
-
 [case testJoinOfTypedDictWithIncompatibleTypeIsObject]
 from mypy_extensions import TypedDict
 from typing import Mapping
@@ -795,13 +787,13 @@ def u(x: T, y: S) -> Union[S, T]: pass
 C = TypedDict('C', {'a': int, 'b': int})
 
 c = C(a=1, b=1)
-m_s_i: Mapping[str, int]
+m_s_o: Mapping[str, object]
 m_s_s: Mapping[str, str]
 m_i_i: Mapping[int, int]
 m_s_a: Mapping[str, Any]
 
-reveal_type(u(c, m_s_i)) # E: Revealed type is 'typing.Mapping*[builtins.str, builtins.int]'
-reveal_type(u(m_s_i, c)) # E: Revealed type is 'typing.Mapping*[builtins.str, builtins.int]'
+reveal_type(u(c, m_s_o)) # E: Revealed type is 'typing.Mapping*[builtins.str, builtins.object]'
+reveal_type(u(m_s_o, c)) # E: Revealed type is 'typing.Mapping*[builtins.str, builtins.object]'
 reveal_type(u(c, m_s_s)) # E: Revealed type is 'Union[typing.Mapping*[builtins.str, builtins.str], TypedDict('__main__.C', {'a': builtins.int, 'b': builtins.int})]'
 reveal_type(u(c, m_i_i)) # E: Revealed type is 'Union[typing.Mapping*[builtins.int, builtins.int], TypedDict('__main__.C', {'a': builtins.int, 'b': builtins.int})]'
 reveal_type(u(c, m_s_a)) # E: Revealed type is 'Union[typing.Mapping*[builtins.str, Any], TypedDict('__main__.C', {'a': builtins.int, 'b': builtins.int})]'
@@ -1297,8 +1289,8 @@ class B: pass
 class C(B): pass
 x: X
 reveal_type(x) # E: Revealed type is 'TypedDict('__main__.X', {'b': __main__.B, 'c': __main__.C})'
-m1: Mapping[str, B] = x
-m2: Mapping[str, C] = x # E: Incompatible types in assignment (expression has type "X", variable has type "Mapping[str, C]")
+m1: Mapping[str, object] = x
+m2: Mapping[str, B] = x # E: Incompatible types in assignment (expression has type "X", variable has type "Mapping[str, B]")
 [builtins fixtures/dict.pyi]
 
 [case testForwardReferenceInClassTypedDict]
@@ -1311,8 +1303,8 @@ class B: pass
 class C(B): pass
 x: X
 reveal_type(x) # E: Revealed type is 'TypedDict('__main__.X', {'b': __main__.B, 'c': __main__.C})'
-m1: Mapping[str, B] = x
-m2: Mapping[str, C] = x # E: Incompatible types in assignment (expression has type "X", variable has type "Mapping[str, C]")
+m1: Mapping[str, object] = x
+m2: Mapping[str, B] = x # E: Incompatible types in assignment (expression has type "X", variable has type "Mapping[str, B]")
 [builtins fixtures/dict.pyi]
 
 [case testForwardReferenceToTypedDictInTypedDict]
diff --git a/test-data/unit/deps-types.test b/test-data/unit/deps-types.test
--- a/test-data/unit/deps-types.test
+++ b/test-data/unit/deps-types.test
@@ -852,7 +852,7 @@ class I: pass
 <m.P> -> m.P
 <mod.I.__init__> -> m
 <mod.I.__new__> -> m
-<mod.I> -> <m.P>, m, m.P
+<mod.I> -> <m.P>, m
 <mypy_extensions.TypedDict> -> m
 
 [case testAliasDepsTypedDictFunctional]
diff --git a/test-data/unit/pythoneval.test b/test-data/unit/pythoneval.test
--- a/test-data/unit/pythoneval.test
+++ b/test-data/unit/pythoneval.test
@@ -1072,8 +1072,8 @@ _testTypedDictMappingMethods.py:6: error: Revealed type is 'typing.Iterator[buil
 _testTypedDictMappingMethods.py:7: error: Revealed type is 'builtins.int'
 _testTypedDictMappingMethods.py:8: error: Revealed type is 'builtins.bool'
 _testTypedDictMappingMethods.py:9: error: Revealed type is 'typing.AbstractSet[builtins.str*]'
-_testTypedDictMappingMethods.py:10: error: Revealed type is 'typing.AbstractSet[Tuple[builtins.str*, builtins.int*]]'
-_testTypedDictMappingMethods.py:11: error: Revealed type is 'typing.ValuesView[builtins.int*]'
+_testTypedDictMappingMethods.py:10: error: Revealed type is 'typing.AbstractSet[Tuple[builtins.str*, builtins.object*]]'
+_testTypedDictMappingMethods.py:11: error: Revealed type is 'typing.ValuesView[builtins.object*]'
 
 [case testCrashOnComplexCheckWithNamedTupleNext]
 from typing import NamedTuple
diff --git a/test-data/unit/semanal-typeddict.test b/test-data/unit/semanal-typeddict.test
--- a/test-data/unit/semanal-typeddict.test
+++ b/test-data/unit/semanal-typeddict.test
@@ -47,7 +47,7 @@ MypyFile:1(
   ClassDef:2(
     A
     BaseType(
-      typing.Mapping[builtins.str, builtins.str])
+      typing.Mapping[builtins.str, builtins.object])
     ExpressionStmt:3(
       StrExpr(foo))
     AssignmentStmt:4(