Repository: python/mypy

Issue:
Unexpected error when assigning dict literal to union containing TypedDict
In general, I'd assume `x: T | U = e` to succeed without error if `e` is assignable to either `T` or `U`. In the below code, the dict literal is not assignable to the typed dict `TD`, but it is assignable to `dict[str, str]`. Although the typed dict case does fail, I don't expect to see any type errors.

```python
from typing import TypedDict

class TD(TypedDict):
    x: int

x: TD | dict[str, str] = { "x": "foo" }
```

Expected behavior: No error

Actual Behavior: Incompatible types (expression has type "str", TypedDict item "x" has type "int")  [typeddict-item]

Mypy version used: 1.17.0 (playground)
Mypy command-line flags: playground defaults
Mypy configuration options from `mypy.ini` (and other config files): N/A
Python version used: 3.12



Discussion:

Patch:
diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py
index 1b10370b08cb..6e0915179f90 100644
--- a/mypy/checkexpr.py
+++ b/mypy/checkexpr.py
@@ -5350,9 +5350,9 @@ def visit_dict_expr(self, e: DictExpr) -> Type:
         # an error, but returns the TypedDict type that matches the literal it found
         # that would cause a second error when that TypedDict type is returned upstream
         # to avoid the second error, we always return TypedDict type that was requested
-        typeddict_contexts = self.find_typeddict_context(self.type_context[-1], e)
+        typeddict_contexts, exhaustive = self.find_typeddict_context(self.type_context[-1], e)
         if typeddict_contexts:
-            if len(typeddict_contexts) == 1:
+            if len(typeddict_contexts) == 1 and exhaustive:
                 return self.check_typeddict_literal_in_context(e, typeddict_contexts[0])
             # Multiple items union, check if at least one of them matches cleanly.
             for typeddict_context in typeddict_contexts:
@@ -5363,7 +5363,8 @@ def visit_dict_expr(self, e: DictExpr) -> Type:
                 self.chk.store_types(tmap)
                 return ret_type
             # No item matched without an error, so we can't unambiguously choose the item.
-            self.msg.typeddict_context_ambiguous(typeddict_contexts, e)
+            if exhaustive:
+                self.msg.typeddict_context_ambiguous(typeddict_contexts, e)
 
         # fast path attempt
         dt = self.fast_dict_type(e)
@@ -5425,22 +5426,29 @@ def visit_dict_expr(self, e: DictExpr) -> Type:
 
     def find_typeddict_context(
         self, context: Type | None, dict_expr: DictExpr
-    ) -> list[TypedDictType]:
+    ) -> tuple[list[TypedDictType], bool]:
+        """Extract `TypedDict` members of the enclosing context.
+
+        Returns:
+            a 2-tuple, (found_candidates, is_exhaustive)
+        """
         context = get_proper_type(context)
         if isinstance(context, TypedDictType):
-            return [context]
+            return [context], True
         elif isinstance(context, UnionType):
             items = []
+            exhaustive = True
             for item in context.items:
-                item_contexts = self.find_typeddict_context(item, dict_expr)
+                item_contexts, item_exhaustive = self.find_typeddict_context(item, dict_expr)
                 for item_context in item_contexts:
                     if self.match_typeddict_call_with_dict(
                         item_context, dict_expr.items, dict_expr
                     ):
                         items.append(item_context)
-            return items
+                exhaustive = exhaustive and item_exhaustive
+            return items, exhaustive
         # No TypedDict type in context.
-        return []
+        return [], False
 
     def visit_lambda_expr(self, e: LambdaExpr) -> Type:
         """Type check lambda expression."""


Test patch:
diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test
index be5a6c655d8c..34cae74d795b 100644
--- a/test-data/unit/check-typeddict.test
+++ b/test-data/unit/check-typeddict.test
@@ -4289,3 +4289,21 @@ inputs: Sequence[Component] = [{
 }]
 [builtins fixtures/dict.pyi]
 [typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictAssignableToWiderContext]
+from typing import TypedDict, Union
+
+class TD(TypedDict):
+    x: int
+
+x: Union[TD, dict[str, str]] = {"x": "foo"}
+y: Union[TD, dict[str, int]] = {"x": "foo"}  # E: Dict entry 0 has incompatible type "str": "str"; expected "str": "int"
+
+def ok(d: Union[TD, dict[str, str]]) -> None: ...
+ok({"x": "foo"})
+
+def bad(d: Union[TD, dict[str, int]]) -> None: ...
+bad({"x": "foo"})  # E: Dict entry 0 has incompatible type "str": "str"; expected "str": "int"
+
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]