[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

View File

@@ -120,6 +120,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) {
registry.register_lint(&REDUNDANT_CAST);
registry.register_lint(&UNRESOLVED_GLOBAL);
registry.register_lint(&MISSING_TYPED_DICT_KEY);
registry.register_lint(&INVALID_TYPED_DICT_STATEMENT);
registry.register_lint(&INVALID_METHOD_OVERRIDE);
registry.register_lint(&INVALID_EXPLICIT_OVERRIDE);
registry.register_lint(&SUPER_CALL_IN_NAMED_TUPLE_METHOD);
@@ -2167,6 +2168,31 @@ declare_lint! {
}
}
declare_lint! {
/// ## What it does
/// Detects statements other than annotated declarations in `TypedDict` class bodies.
///
/// ## Why is this bad?
/// `TypedDict` class bodies aren't allowed to contain any other types of statements. For
/// example, method definitions and field values aren't allowed. None of these will be
/// available on "instances of the `TypedDict`" at runtime (as `dict` is the runtime class of
/// all "`TypedDict` instances").
///
/// ## Example
/// ```python
/// from typing import TypedDict
///
/// class Foo(TypedDict):
/// def bar(self): # error: [invalid-typed-dict-statement]
/// pass
/// ```
pub(crate) static INVALID_TYPED_DICT_STATEMENT = {
summary: "detects invalid statements in `TypedDict` class bodies",
status: LintStatus::stable("0.0.9"),
default_level: Level::Error,
}
}
declare_lint! {
/// ## What it does
/// Detects method overrides that violate the [Liskov Substitution Principle] ("LSP").

View File

@@ -63,11 +63,11 @@ use crate::types::diagnostic::{
INVALID_LEGACY_TYPE_VARIABLE, INVALID_METACLASS, INVALID_NAMED_TUPLE, INVALID_NEWTYPE,
INVALID_OVERLOAD, INVALID_PARAMETER_DEFAULT, INVALID_PARAMSPEC, INVALID_PROTOCOL,
INVALID_TYPE_ARGUMENTS, INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL,
INVALID_TYPE_VARIABLE_CONSTRAINTS, IncompatibleBases, NOT_SUBSCRIPTABLE,
POSSIBLY_MISSING_ATTRIBUTE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT,
SUBCLASS_OF_FINAL_CLASS, TypedDictDeleteErrorKind, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE,
UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR,
USELESS_OVERLOAD_BODY, hint_if_stdlib_attribute_exists_on_other_versions,
INVALID_TYPE_VARIABLE_CONSTRAINTS, INVALID_TYPED_DICT_STATEMENT, IncompatibleBases,
NOT_SUBSCRIPTABLE, POSSIBLY_MISSING_ATTRIBUTE, POSSIBLY_MISSING_IMPLICIT_CALL,
POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_FINAL_CLASS, TypedDictDeleteErrorKind, UNDEFINED_REVEAL,
UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE,
UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, hint_if_stdlib_attribute_exists_on_other_versions,
hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation,
report_bad_dunder_set_call, report_bad_frozen_dataclass_inheritance,
report_cannot_delete_typed_dict_key, report_cannot_pop_required_field_on_typed_dict,
@@ -1054,6 +1054,67 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
if let Some(protocol) = class.into_protocol_class(self.db()) {
protocol.validate_members(&self.context);
}
// (9) If it's a `TypedDict` class, check that it doesn't include any invalid
// statements: https://typing.python.org/en/latest/spec/typeddict.html#class-based-syntax
//
// The body of the class definition defines the items of the `TypedDict` type. It
// may also contain a docstring or pass statements (primarily to allow the creation
// of an empty `TypedDict`). No other statements are allowed, and type checkers
// should report an error if any are present.
if class.is_typed_dict(self.db()) {
for stmt in &class_node.body {
match stmt {
// Annotated assignments are allowed (that's the whole point), but they're
// not allowed to have a value.
ast::Stmt::AnnAssign(ann_assign) => {
if let Some(value) = &ann_assign.value {
if let Some(builder) = self
.context
.report_lint(&INVALID_TYPED_DICT_STATEMENT, &**value)
{
builder.into_diagnostic(format_args!(
"TypedDict item cannot have a value"
));
}
}
continue;
}
// Pass statements are allowed.
ast::Stmt::Pass(_) => continue,
ast::Stmt::Expr(expr) => {
// Docstrings are allowed.
if matches!(*expr.value, ast::Expr::StringLiteral(_)) {
continue;
}
// As a non-standard but common extension, we also interpret `...` as
// equivalent to `pass`.
if matches!(*expr.value, ast::Expr::EllipsisLiteral(_)) {
continue;
}
}
// Everything else is forbidden.
_ => {}
}
if let Some(builder) = self
.context
.report_lint(&INVALID_TYPED_DICT_STATEMENT, stmt)
{
if matches!(stmt, ast::Stmt::FunctionDef(_)) {
builder.into_diagnostic(format_args!(
"TypedDict class cannot have methods"
));
} else {
let mut diagnostic = builder.into_diagnostic(format_args!(
"invalid statement in TypedDict class body"
));
diagnostic.info(
"Only annotated declarations (`<name>: <type>`) are allowed.",
);
}
}
}
}
}
}