Repository: python/mypy (Python)

Issue:
Different behaviour for recursive TypedDict defined as class/functional
**Bug Report**

`TypedDict`-based recursive type is flagged as invalid when defined using functional syntax while no issue is reported for analogous class-based definition.

**To Reproduce**

```python
from typing import List, TypedDict

Example = TypedDict("Example", {"rec": List["Example"]})
```

**Expected Behavior**

`TypedDict`-based recursive types to have similar behaviour when defined using class/functional syntax.

**Actual Behavior**

```python
from typing import List, TypedDict

Example = TypedDict("Example", {"rec": List["Example"]})
```

results in an error:
> Cannot resolve name "Example" (possible cyclic definition)

while similar type defined as a class has no issues reported:

```python
from typing import List, TypedDict

class Example(TypedDict):
    rec: List["Example"]
```

Also no issue is reported when the recursive property is defined using some of the special forms (like `Required`, `Optional` etc) instead of `List`.  The following code passes typecheck without errors:

```python
from typing import Optional, TypedDict

Example = TypedDict("Example", {"rec": Optional["Example"]})
```

**Your Environment**

- Mypy version used: `mypy 0.991 (compiled: yes)`
- Mypy command-line flags: `none`
- Mypy configuration options from `mypy.ini` (and other config files): `none`
- Python version used: `3.11.1`



Hints:

Patch:
diff --git a/mypy/semanal.py b/mypy/semanal.py
--- a/mypy/semanal.py
+++ b/mypy/semanal.py
@@ -2648,7 +2648,7 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
         # But we can't use a full visit because it may emit extra incomplete refs (namely
         # when analysing any type applications there) thus preventing the further analysis.
         # To break the tie, we first analyse rvalue partially, if it can be a type alias.
-        if self.can_possibly_be_index_alias(s):
+        if self.can_possibly_be_type_form(s):
             old_basic_type_applications = self.basic_type_applications
             self.basic_type_applications = True
             with self.allow_unbound_tvars_set():
@@ -2664,7 +2664,7 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
             for expr in names_modified_by_assignment(s):
                 self.mark_incomplete(expr.name, expr)
             return
-        if self.can_possibly_be_index_alias(s):
+        if self.can_possibly_be_type_form(s):
             # Now re-visit those rvalues that were we skipped type applications above.
             # This should be safe as generally semantic analyzer is idempotent.
             with self.allow_unbound_tvars_set():
@@ -2807,16 +2807,19 @@ def can_be_type_alias(self, rv: Expression, allow_none: bool = False) -> bool:
                 return True
         return False
 
-    def can_possibly_be_index_alias(self, s: AssignmentStmt) -> bool:
-        """Like can_be_type_alias(), but simpler and doesn't require analyzed rvalue.
+    def can_possibly_be_type_form(self, s: AssignmentStmt) -> bool:
+        """Like can_be_type_alias(), but simpler and doesn't require fully analyzed rvalue.
 
-        Instead, use lvalues/annotations structure to figure out whether this can
-        potentially be a type alias definition. Another difference from above function
-        is that we are only interested IndexExpr and OpExpr rvalues, since only those
+        Instead, use lvalues/annotations structure to figure out whether this can potentially be
+        a type alias definition, NamedTuple, or TypedDict. Another difference from above function
+        is that we are only interested IndexExpr, CallExpr and OpExpr rvalues, since only those
         can be potentially recursive (things like `A = A` are never valid).
         """
         if len(s.lvalues) > 1:
             return False
+        if isinstance(s.rvalue, CallExpr) and isinstance(s.rvalue.callee, RefExpr):
+            ref = s.rvalue.callee.fullname
+            return ref in TPDICT_NAMES or ref in TYPED_NAMEDTUPLE_NAMES
         if not isinstance(s.lvalues[0], NameExpr):
             return False
         if s.unanalyzed_type is not None and not self.is_pep_613(s):


Test patch:
diff --git a/test-data/unit/check-recursive-types.test b/test-data/unit/check-recursive-types.test
--- a/test-data/unit/check-recursive-types.test
+++ b/test-data/unit/check-recursive-types.test
@@ -880,3 +880,20 @@ class InListRecurse(Generic[T], List[InList[T]]): ...
 def list_thing(transforming: InList[T]) -> T:
     ...
 reveal_type(list_thing([5]))  # N: Revealed type is "builtins.list[builtins.int]"
+
+[case testRecursiveTypedDictWithList]
+from typing import List
+from typing_extensions import TypedDict
+
+Example = TypedDict("Example", {"rec": List["Example"]})
+e: Example
+reveal_type(e)  # N: Revealed type is "TypedDict('__main__.Example', {'rec': builtins.list[...]})"
+[builtins fixtures/dict.pyi]
+
+[case testRecursiveNamedTupleWithList]
+from typing import List, NamedTuple
+
+Example = NamedTuple("Example", [("rec", List["Example"])])
+e: Example
+reveal_type(e)  # N: Revealed type is "Tuple[builtins.list[...], fallback=__main__.Example]"
+[builtins fixtures/tuple.pyi]