[ty] emit diagnostics for method definitions and other invalid statements in TypedDict class bodies (#22351)

Fixes https://github.com/astral-sh/ty/issues/2277.
This commit is contained in:
Jack O'Connor
2026-01-05 11:28:04 -08:00
committed by GitHub
parent 4712503c6d
commit 922d964bcb
7 changed files with 354 additions and 84 deletions

View File

@@ -551,10 +551,8 @@ class MyNamedTupleChild(MyNamedTupleParent):
class MyTypedDict(TypedDict):
x: int
# error: [invalid-typed-dict-statement] "TypedDict class cannot have methods"
@override
# TODO: it's invalid to define a method on a `TypedDict` class,
# so we should emit a diagnostic here.
# It shouldn't be an `invalid-explicit-override` diagnostic, however.
def copy(self) -> Self: ...
class Grandparent(Any): ...

View File

@@ -0,0 +1,103 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: typed_dict.md - `TypedDict` - Only annotated declarations are allowed in the class body
mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing import TypedDict
2 |
3 | class Foo(TypedDict):
4 | """docstring"""
5 |
6 | annotated_item: int
7 | """attribute docstring"""
8 |
9 | pass
10 |
11 | # As a non-standard but common extension, we interpret `...` as equivalent to `pass`.
12 | ...
13 |
14 | class Bar(TypedDict):
15 | a: int
16 | # error: [invalid-typed-dict-statement] "invalid statement in TypedDict class body"
17 | 42
18 | # error: [invalid-typed-dict-statement] "TypedDict item cannot have a value"
19 | b: str = "hello"
20 | # error: [invalid-typed-dict-statement] "TypedDict class cannot have methods"
21 | def bar(self): ...
22 | class Baz(Bar):
23 | # error: [invalid-typed-dict-statement]
24 | def baz(self):
25 | pass
```
# Diagnostics
```
error[invalid-typed-dict-statement]: invalid statement in TypedDict class body
--> src/mdtest_snippet.py:17:5
|
15 | a: int
16 | # error: [invalid-typed-dict-statement] "invalid statement in TypedDict class body"
17 | 42
| ^^
18 | # error: [invalid-typed-dict-statement] "TypedDict item cannot have a value"
19 | b: str = "hello"
|
info: Only annotated declarations (`<name>: <type>`) are allowed.
info: rule `invalid-typed-dict-statement` is enabled by default
```
```
error[invalid-typed-dict-statement]: TypedDict item cannot have a value
--> src/mdtest_snippet.py:19:14
|
17 | 42
18 | # error: [invalid-typed-dict-statement] "TypedDict item cannot have a value"
19 | b: str = "hello"
| ^^^^^^^
20 | # error: [invalid-typed-dict-statement] "TypedDict class cannot have methods"
21 | def bar(self): ...
|
info: rule `invalid-typed-dict-statement` is enabled by default
```
```
error[invalid-typed-dict-statement]: TypedDict class cannot have methods
--> src/mdtest_snippet.py:21:5
|
19 | b: str = "hello"
20 | # error: [invalid-typed-dict-statement] "TypedDict class cannot have methods"
21 | def bar(self): ...
| ^^^^^^^^^^^^^^^^^^
22 | class Baz(Bar):
23 | # error: [invalid-typed-dict-statement]
|
info: rule `invalid-typed-dict-statement` is enabled by default
```
```
error[invalid-typed-dict-statement]: TypedDict class cannot have methods
--> src/mdtest_snippet.py:24:5
|
22 | class Baz(Bar):
23 | # error: [invalid-typed-dict-statement]
24 | / def baz(self):
25 | | pass
| |____________^
|
info: rule `invalid-typed-dict-statement` is enabled by default
```

View File

@@ -2266,6 +2266,47 @@ def match_with_dict(u: Foo | Bar | dict):
reveal_type(u) # revealed: Foo | (dict[Unknown, Unknown] & ~<TypedDict with items 'tag'>)
```
## Only annotated declarations are allowed in the class body
<!-- snapshot-diagnostics -->
`TypedDict` class bodies are very restricted in what kinds of statements they can contain. Besides
annotated items, the only allowed statements are docstrings and `pass`. Annotated items are are also
not allowed to have a value.
```py
from typing import TypedDict
class Foo(TypedDict):
"""docstring"""
annotated_item: int
"""attribute docstring"""
pass
# As a non-standard but common extension, we interpret `...` as equivalent to `pass`.
...
class Bar(TypedDict):
a: int
# error: [invalid-typed-dict-statement] "invalid statement in TypedDict class body"
42
# error: [invalid-typed-dict-statement] "TypedDict item cannot have a value"
b: str = "hello"
# error: [invalid-typed-dict-statement] "TypedDict class cannot have methods"
def bar(self): ...
```
These rules are also enforced for `TypedDict` classes that don't directly inherit from `TypedDict`:
```py
class Baz(Bar):
# error: [invalid-typed-dict-statement]
def baz(self):
pass
```
[closed]: https://peps.python.org/pep-0728/#disallowing-extra-items-explicitly
[subtyping section]: https://typing.python.org/en/latest/spec/typeddict.html#subtyping-between-typeddict-types
[`typeddict`]: https://typing.python.org/en/latest/spec/typeddict.html