Repository: python/mypy
Issue category: Bug Report

Issue:
`arg-type` error duck typing a `TypedDict` with `NotRequired` field
**Bug Report**

[mypy play url](https://mypy-play.net/?mypy=latest&python=3.12&gist=e6a8b78acddafd1ad10e81364e8e9ab5)

it seems that mypy doesn't "match" a `NotRequired` field with a normal one when duck typing 2 different typed dict:

Let's say that I have a function `foo` that expect a `Small` TypedDict as parameter

```python
class Small(TypedDict):
    a: NotRequired[str]

def foo(small: Small) -> None: ...
```

In this `Small` TypedDict the field `a` is marked as `NotRequired`.

Elsewere I have another `Big` TypedDict with 2 fields `a`, and `b`, both required.

```python
class Big(TypedDict):
    a: str
    b: str
```

but if I try to pass a `Big` dict instance to my `foo` function, mypy mark it as an error:
`main.py:21: error: Argument 1 to "foo" has incompatible type "Big"; expected "Small"  [arg-type]`

If I change the type of `Big.a` to a `NotRequired[str]`, mypy is happy, however mypy should allow the original definition, as a "normal" field should be treated as a stricter, "smaller" version of a not required one



Hints:
One wrinkle is that `Small` allows deleting keys whereas `Big` does not, which makes passing a `Big` where a `Small` is expected potentially unsound.

Consider:
```python
from typing import NotRequired, TypedDict

class Big(TypedDict):
    a: str
    b: str
    
class Small(TypedDict):
    a: NotRequired[str]
    
def foo(small: Small) -> None:
    del small["a"]  # ok, deleting NotRequired keys is permitted
    
x = Big(a="a", b="b")
foo(x)  # danger! If this was legal, x would no longer be a valid Big
```

However, if you prevent key deletion using [`ReadOnly`](https://peps.python.org/pep-0705/), I think this would be safe. Pyright seems to support this, provided that the `NotRequired` fields are `ReadOnly`: [[pyright playground]](https://pyright-play.net/?pythonVersion=3.12&code=GYJw9gtgBALgngBwJYDsDmUkQWEMoByYMASgKYCOArkiGQCYA0UAKogwCJIDGMAsAChQkWIlRoA%2BmQAeMMigDOSMIszZc%2BcgEN6AeRQAbOIMHcDWhQqgAhJGgAUbBJx4wAlAC5BUH1C0eoBRgQb18AIwCgkIFfKFNzSygAZQgtAwNHdnouXk9Qn38obT1DOABtIlJKGjp6MqiAXQb8uIF6MmAoYDAwewVU9ICUtIM3KABaAD5CFTIvGN8ECwUWgGIodoNAgYMygCItPeaFn0FpKABeGzt7LQuDveYw%2B7C9t0Fu3uk3IA)

Patch:
diff --git a/mypy/subtypes.py b/mypy/subtypes.py
index 11f3421331a5..a26aaf798b58 100644
--- a/mypy/subtypes.py
+++ b/mypy/subtypes.py
@@ -910,14 +910,12 @@ def visit_typeddict_type(self, left: TypedDictType) -> bool:
                     return False
                 # Non-required key is not compatible with a required key since
                 # indexing may fail unexpectedly if a required key is missing.
-                # Required key is not compatible with a non-required key since
-                # the prior doesn't support 'del' but the latter should support
-                # it.
-                #
-                # NOTE: 'del' support is currently not implemented (#3550). We
-                #       don't want to have to change subtyping after 'del' support
-                #       lands so here we are anticipating that change.
-                if (name in left.required_keys) != (name in right.required_keys):
+                # Required key is not compatible with a non-read-only non-required
+                # key since the prior doesn't support 'del' but the latter should
+                # support it.
+                # Required key is compatible with a read-only non-required key.
+                required_differ = (name in left.required_keys) != (name in right.required_keys)
+                if not right_readonly and required_differ:
                     return False
                 # Readonly fields check:
                 #


Test patch:
diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test
index affa472bb640..521a5b80573d 100644
--- a/test-data/unit/check-typeddict.test
+++ b/test-data/unit/check-typeddict.test
@@ -3894,6 +3894,20 @@ accepts_B(b)
 [builtins fixtures/dict.pyi]
 [typing fixtures/typing-typeddict.pyi]
 
+[case testTypedDictRequiredConsistentWithNotRequiredReadOnly]
+from typing import NotRequired, ReadOnly, Required, TypedDict
+
+class A(TypedDict):
+    x: NotRequired[ReadOnly[str]]
+
+class B(TypedDict):
+    x: Required[str]
+
+def f(b: B):
+    a: A = b  # ok
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
 [case testTypedDictReadOnlyCall]
 from typing import ReadOnly, TypedDict