[ty] Infer types for key-based access on TypedDicts (#19763)

## Summary

This PR adds type inference for key-based access on `TypedDict`s and a
new diagnostic for invalid subscript accesses:

```py
class Person(TypedDict):
    name: str
    age: int | None

alice = Person(name="Alice", age=25)

reveal_type(alice["name"])  # revealed: str
reveal_type(alice["age"])  # revealed: int | None

alice["naem"]  # Unknown key "naem" - did you mean "name"?
```

## Test Plan

Updated Markdown tests
This commit is contained in:
David Peter
2025-08-06 09:36:33 +02:00
committed by GitHub
parent e917d309f1
commit 4887bdf205
13 changed files with 489 additions and 158 deletions

View File

@@ -244,8 +244,7 @@ class D(TypedDict):
td = D(x=1, label="a")
td["x"] = 0
# TODO: should be Literal[0]
reveal_type(td["x"]) # revealed: @Todo(Support for `TypedDict`)
reveal_type(td["x"]) # revealed: Literal[0]
# error: [unresolved-reference]
does["not"]["exist"] = 0

View File

@@ -0,0 +1,77 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: typed_dict.md - `TypedDict` - Diagnostics
mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing import TypedDict, Final
2 |
3 | class Person(TypedDict):
4 | name: str
5 | age: int | None
6 |
7 | def access_invalid_literal_string_key(person: Person):
8 | person["naem"] # error: [invalid-key]
9 |
10 | NAME_KEY: Final = "naem"
11 |
12 | def access_invalid_key(person: Person):
13 | person[NAME_KEY] # error: [invalid-key]
14 |
15 | def access_with_str_key(person: Person, str_key: str):
16 | person[str_key] # error: [invalid-key]
```
# Diagnostics
```
error[invalid-key]: Invalid key access on TypedDict `Person`
--> src/mdtest_snippet.py:8:5
|
7 | def access_invalid_literal_string_key(person: Person):
8 | person["naem"] # error: [invalid-key]
| ------ ^^^^^^ Unknown key "naem" - did you mean "name"?
| |
| TypedDict `Person`
9 |
10 | NAME_KEY: Final = "naem"
|
info: rule `invalid-key` is enabled by default
```
```
error[invalid-key]: Invalid key access on TypedDict `Person`
--> src/mdtest_snippet.py:13:5
|
12 | def access_invalid_key(person: Person):
13 | person[NAME_KEY] # error: [invalid-key]
| ------ ^^^^^^^^ Unknown key "naem" - did you mean "name"?
| |
| TypedDict `Person`
14 |
15 | def access_with_str_key(person: Person, str_key: str):
|
info: rule `invalid-key` is enabled by default
```
```
error[invalid-key]: TypedDict `Person` cannot be indexed with a key of type `str`
--> src/mdtest_snippet.py:16:12
|
15 | def access_with_str_key(person: Person, str_key: str):
16 | person[str_key] # error: [invalid-key]
| ^^^^^^^
|
info: rule `invalid-key` is enabled by default
```

View File

@@ -25,12 +25,21 @@ alice: Person = {"name": "Alice", "age": 30}
reveal_type(alice["name"]) # revealed: Unknown
# TODO: this should be `int | None`
reveal_type(alice["age"]) # revealed: Unknown
# TODO: this should reveal `Unknown`, and it should emit an error
reveal_type(alice["non_existing"]) # revealed: Unknown
```
Inhabitants can also be created through a constructor call:
```py
bob = Person(name="Bob", age=25)
reveal_type(bob["name"]) # revealed: str
reveal_type(bob["age"]) # revealed: int | None
# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "non_existing""
reveal_type(bob["non_existing"]) # revealed: Unknown
```
Methods that are available on `dict`s are also available on `TypedDict`s:
@@ -127,6 +136,39 @@ dangerous(alice)
reveal_type(alice["name"]) # revealed: Unknown
```
## Key-based access
```py
from typing import TypedDict, Final, Literal, Any
class Person(TypedDict):
name: str
age: int | None
NAME_FINAL: Final = "name"
AGE_FINAL: Final[Literal["age"]] = "age"
def _(person: Person, literal_key: Literal["age"], union_of_keys: Literal["age", "name"], str_key: str, unknown_key: Any) -> None:
reveal_type(person["name"]) # revealed: str
reveal_type(person["age"]) # revealed: int | None
reveal_type(person[NAME_FINAL]) # revealed: str
reveal_type(person[AGE_FINAL]) # revealed: int | None
reveal_type(person[literal_key]) # revealed: int | None
reveal_type(person[union_of_keys]) # revealed: int | None | str
# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "non_existing""
reveal_type(person["non_existing"]) # revealed: Unknown
# error: [invalid-key] "TypedDict `Person` cannot be indexed with a key of type `str`"
reveal_type(person[str_key]) # revealed: Unknown
# No error here:
reveal_type(person[unknown_key]) # revealed: Unknown
```
## Methods on `TypedDict`
```py
@@ -333,4 +375,29 @@ reveal_type(Message.__required_keys__) # revealed: @Todo(Support for `TypedDict
msg.content
```
## Diagnostics
<!-- snapshot-diagnostics -->
Snapshot tests for diagnostic messages including suggestions:
```py
from typing import TypedDict, Final
class Person(TypedDict):
name: str
age: int | None
def access_invalid_literal_string_key(person: Person):
person["naem"] # error: [invalid-key]
NAME_KEY: Final = "naem"
def access_invalid_key(person: Person):
person[NAME_KEY] # error: [invalid-key]
def access_with_str_key(person: Person, str_key: str):
person[str_key] # error: [invalid-key]
```
[`typeddict`]: https://typing.python.org/en/latest/spec/typeddict.html