Skip to content

[ty] Emit diagnostic for functional TypedDict with non-literal name#24331

Merged
charliermarsh merged 2 commits intomainfrom
charlie/str-name
Mar 31, 2026
Merged

[ty] Emit diagnostic for functional TypedDict with non-literal name#24331
charliermarsh merged 2 commits intomainfrom
charlie/str-name

Conversation

@charliermarsh
Copy link
Copy Markdown
Member

Summary

See: #24295 (comment).

@astral-sh-bot astral-sh-bot bot added the ty Multi-file analysis & type inference label Mar 31, 2026

def g(x: str) -> None:
# error: [invalid-argument-type] "The first argument to `TypedDict` must be a string literal"
TypedDict(x, {})
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see a case for not raising this...?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'd be inclined to avoid the diagnostic on this one and also simplify the code a little, e.g.

diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md
index 7a7de94f98..ff0b5dce9a 100644
--- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md
+++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md
@@ -2529,19 +2529,18 @@ Movie2 = TypedDict("Movie2", name=str, year=int)
 ```py
 from typing_extensions import TypedDict
 
-# error: [invalid-argument-type] "Invalid argument to parameter `typename` of `TypedDict()`"
+# error: [invalid-argument-type] "TypedDict name must match the variable it is assigned to: Expected "Bad1", got variable of type `Literal[123]`"
 Bad1 = TypedDict(123, {"name": str})
 
-# error: [invalid-argument-type] "The name of a `TypedDict` (`WrongName`) must match the name of the variable it is assigned to (`BadTypedDict3`)"
+# error: [invalid-argument-type] "TypedDict name must match the variable it is assigned to: Expected "BadTypedDict3", got "WrongName""
 BadTypedDict3 = TypedDict("WrongName", {"name": str})
 
 def f(x: str) -> None:
-    # error: [invalid-argument-type] "The first argument to `TypedDict` must be the string literal `Y`"
+    # error: [invalid-argument-type] "TypedDict name must match the variable it is assigned to: Expected "Y", got variable of type `str`"
     Y = TypedDict(x, {})
 
 def g(x: str) -> None:
-    # error: [invalid-argument-type] "The first argument to `TypedDict` must be a string literal"
-    TypedDict(x, {})
+    TypedDict(x, {})  # fine
 
 name = "GoodTypedDict"
 GoodTypedDict = TypedDict(name, {"name": str})
diff --git a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs
index c22fd8367e..e7bff0d29a 100644
--- a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs
@@ -158,45 +158,30 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
             );
         }
 
-        let name = if let Some(literal) = name_type.as_string_literal() {
-            let name = literal.value(db);
-
-            if let Some(assigned_name) = definition.and_then(|definition| definition.name(db))
-                && name != assigned_name
-                && let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, name_arg)
-            {
-                builder.into_diagnostic(format_args!(
-                    "The name of a `TypedDict` (`{name}`) must match \
-                    the name of the variable it is assigned to (`{assigned_name}`)"
+        let name = name_type
+            .as_string_literal()
+            .map(|literal| Name::new(literal.value(db)));
+
+        if let Some(definition) = definition
+            && let Some(assigned_name) = definition.name(db)
+            && Some(&*assigned_name) != name.as_deref()
+            && let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, name_arg)
+        {
+            let mut diagnostic =
+                builder.into_diagnostic("TypedDict name must match the variable it is assigned to");
+            if let Some(name) = name.as_deref() {
+                diagnostic.set_primary_message(format_args!(
+                    "Expected \"{assigned_name}\", got \"{name}\""
+                ));
+            } else {
+                diagnostic.set_primary_message(format_args!(
+                    "Expected \"{assigned_name}\", got variable of type `{}`",
+                    name_type.display(db)
                 ));
             }
+        }
 
-            Name::new(name)
-        } else {
-            let is_str = name_type.is_assignable_to(db, KnownClass::Str.to_instance(db));
-            if let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, name_arg) {
-                if let Some(assigned_name) = definition.and_then(|definition| definition.name(db))
-                    && is_str
-                {
-                    builder.into_diagnostic(format_args!(
-                        "The first argument to `TypedDict` must be the string literal `{assigned_name}`"
-                    ));
-                } else if is_str {
-                    builder.into_diagnostic(
-                        "The first argument to `TypedDict` must be a string literal",
-                    );
-                } else {
-                    let mut diagnostic = builder.into_diagnostic(format_args!(
-                        "Invalid argument to parameter `typename` of `TypedDict()`"
-                    ));
-                    diagnostic.set_primary_message(format_args!(
-                        "Expected `str`, found `{}`",
-                        name_type.display(db)
-                    ));
-                }
-            }
-            Name::new_static("<unknown>")
-        };
+        let name = name.unwrap_or_else(|| Name::new_static("<unknown>"));
 
         if let Some(definition) = definition {
             self.deferred.insert(definition);

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot bot commented Mar 31, 2026

Typing conformance results

The percentage of diagnostics emitted that were expected errors held steady at 86.61%. The percentage of expected errors that received a diagnostic held steady at 81.56%. The number of fully passing files held steady at 70/132.

Summary

How are test cases classified?

Each test case represents one expected error annotation or a group of annotations sharing a tag. Counts are per test case, not per diagnostic — multiple diagnostics on the same line count as one. Required annotations (E) are true positives when ty flags the expected location and false negatives when it does not. Optional annotations (E?) are true positives when flagged but true negatives (not false negatives) when not. Tagged annotations (E[tag]) require ty to flag exactly one of the tagged lines; tagged multi-annotations (E[tag+]) allow any number up to the tag count. Flagging unexpected locations counts as a false positive.

Metric Old New Diff Outcome
True Positives 867 867 +0
False Positives 134 134 +0
False Negatives 196 196 +0
Total Diagnostics 1064 1064 +0
Precision 86.61% 86.61% +0.00%
Recall 81.56% 81.56% +0.00%
Passing Files 70/132 70/132 +0

True positives changed (1)

1 diagnostic
Test case Diff

typeddicts_alt_syntax.py:31

-error[invalid-argument-type] The name of a `TypedDict` (`WrongName`) must match the name of the variable it is assigned to (`BadTypedDict3`)
+error[invalid-argument-type] TypedDict name must match the variable it is assigned to: Expected "BadTypedDict3", got "WrongName"

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot bot commented Mar 31, 2026

Memory usage report

Memory usage unchanged ✅

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot bot commented Mar 31, 2026

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-await 0 40 0
invalid-return-type 0 1 0
Total 0 41 0

Changes in flaky projects detected. Raw diff output excludes flaky projects; see the HTML report for details.

Full report with detailed diff (timing results)

@charliermarsh charliermarsh marked this pull request as ready for review March 31, 2026 14:26
@carljm carljm removed their request for review March 31, 2026 14:52
Copy link
Copy Markdown
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you!


def g(x: str) -> None:
# error: [invalid-argument-type] "The first argument to `TypedDict` must be a string literal"
TypedDict(x, {})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'd be inclined to avoid the diagnostic on this one and also simplify the code a little, e.g.

diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md
index 7a7de94f98..ff0b5dce9a 100644
--- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md
+++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md
@@ -2529,19 +2529,18 @@ Movie2 = TypedDict("Movie2", name=str, year=int)
 ```py
 from typing_extensions import TypedDict
 
-# error: [invalid-argument-type] "Invalid argument to parameter `typename` of `TypedDict()`"
+# error: [invalid-argument-type] "TypedDict name must match the variable it is assigned to: Expected "Bad1", got variable of type `Literal[123]`"
 Bad1 = TypedDict(123, {"name": str})
 
-# error: [invalid-argument-type] "The name of a `TypedDict` (`WrongName`) must match the name of the variable it is assigned to (`BadTypedDict3`)"
+# error: [invalid-argument-type] "TypedDict name must match the variable it is assigned to: Expected "BadTypedDict3", got "WrongName""
 BadTypedDict3 = TypedDict("WrongName", {"name": str})
 
 def f(x: str) -> None:
-    # error: [invalid-argument-type] "The first argument to `TypedDict` must be the string literal `Y`"
+    # error: [invalid-argument-type] "TypedDict name must match the variable it is assigned to: Expected "Y", got variable of type `str`"
     Y = TypedDict(x, {})
 
 def g(x: str) -> None:
-    # error: [invalid-argument-type] "The first argument to `TypedDict` must be a string literal"
-    TypedDict(x, {})
+    TypedDict(x, {})  # fine
 
 name = "GoodTypedDict"
 GoodTypedDict = TypedDict(name, {"name": str})
diff --git a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs
index c22fd8367e..e7bff0d29a 100644
--- a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs
@@ -158,45 +158,30 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
             );
         }
 
-        let name = if let Some(literal) = name_type.as_string_literal() {
-            let name = literal.value(db);
-
-            if let Some(assigned_name) = definition.and_then(|definition| definition.name(db))
-                && name != assigned_name
-                && let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, name_arg)
-            {
-                builder.into_diagnostic(format_args!(
-                    "The name of a `TypedDict` (`{name}`) must match \
-                    the name of the variable it is assigned to (`{assigned_name}`)"
+        let name = name_type
+            .as_string_literal()
+            .map(|literal| Name::new(literal.value(db)));
+
+        if let Some(definition) = definition
+            && let Some(assigned_name) = definition.name(db)
+            && Some(&*assigned_name) != name.as_deref()
+            && let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, name_arg)
+        {
+            let mut diagnostic =
+                builder.into_diagnostic("TypedDict name must match the variable it is assigned to");
+            if let Some(name) = name.as_deref() {
+                diagnostic.set_primary_message(format_args!(
+                    "Expected \"{assigned_name}\", got \"{name}\""
+                ));
+            } else {
+                diagnostic.set_primary_message(format_args!(
+                    "Expected \"{assigned_name}\", got variable of type `{}`",
+                    name_type.display(db)
                 ));
             }
+        }
 
-            Name::new(name)
-        } else {
-            let is_str = name_type.is_assignable_to(db, KnownClass::Str.to_instance(db));
-            if let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, name_arg) {
-                if let Some(assigned_name) = definition.and_then(|definition| definition.name(db))
-                    && is_str
-                {
-                    builder.into_diagnostic(format_args!(
-                        "The first argument to `TypedDict` must be the string literal `{assigned_name}`"
-                    ));
-                } else if is_str {
-                    builder.into_diagnostic(
-                        "The first argument to `TypedDict` must be a string literal",
-                    );
-                } else {
-                    let mut diagnostic = builder.into_diagnostic(format_args!(
-                        "Invalid argument to parameter `typename` of `TypedDict()`"
-                    ));
-                    diagnostic.set_primary_message(format_args!(
-                        "Expected `str`, found `{}`",
-                        name_type.display(db)
-                    ));
-                }
-            }
-            Name::new_static("<unknown>")
-        };
+        let name = name.unwrap_or_else(|| Name::new_static("<unknown>"));
 
         if let Some(definition) = definition {
             self.deferred.insert(definition);

@AlexWaygood AlexWaygood changed the title [ty] Raise diagnostic for functional TypedDict with non-literal name [ty] Emit diagnostic for functional TypedDict with non-literal name Mar 31, 2026
@charliermarsh charliermarsh enabled auto-merge (squash) March 31, 2026 17:07
@charliermarsh charliermarsh merged commit eb76688 into main Mar 31, 2026
50 checks passed
@charliermarsh charliermarsh deleted the charlie/str-name branch March 31, 2026 17:09
carljm added a commit that referenced this pull request Apr 1, 2026
* main:
  [ty] Add missing test case for inline functional TypedDict with an invalid type passed to the `name` parameter (#24334)
  [ty] Use `_cls` as argument name for `collections.namedtuple` (#24333)
  [ty] Emit diagnostic for functional TypedDict with non-literal name (#24331)
  Add `nested-string-quote-style` formatting option (#24312)
  `RUF010`: Mark fix as unsafe when it deletes a comment
  [ty] Fix semantic token classification for properties accessed on instances (#24065)
  publish installers to `/installers/ruff/latest` on the mirror (#24247)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants