[ty] Initial test suite for TypedDict (#19686)

## Summary

Adds an initial set of tests based on the highest-priority items in
https://github.com/astral-sh/ty/issues/154. This is certainly not yet
exhaustive (required/non-required, `total`, and other things are
missing), but will be useful to measure progress on this feature.

## Test Plan

Checked intended behavior against runtime and other type checkers.
This commit is contained in:
David Peter 2025-08-01 16:56:02 +02:00 committed by GitHub
parent e7e7b7bf21
commit 48d5bd13fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,16 +1,267 @@
# `TypedDict`
We do not support `TypedDict`s yet. This test mainly exists to make sure that we do not emit any
errors for the definition of a `TypedDict`.
A [`TypedDict`] type represents dictionary objects with a specific set of string keys, and with
specific value types for each valid key. Each string key can be either required or non-required.
## Basic
Here, we define a `TypedDict` using the class-based syntax:
```py
from typing_extensions import TypedDict, Required
from typing import TypedDict
class Person(TypedDict):
name: str
age: int | None
```
New inhabitants can be created from dict literals. When accessing keys, the correct types should be
inferred based on the `TypedDict` definition:
```py
alice: Person = {"name": "Alice", "age": 30}
# TODO: this should be `str`
reveal_type(alice["name"]) # revealed: Unknown
# TODO: this should be `int | None`
reveal_type(alice["age"]) # revealed: Unknown
```
Inhabitants can also be created through a constructor call:
```py
bob = Person(name="Bob", age=25)
```
Methods that are available on `dict`s are also available on `TypedDict`s:
```py
bob.update(age=26)
```
The construction of a `TypedDict` is checked for type correctness:
```py
# TODO: these should be errors (invalid argument type)
eve1a: Person = {"name": b"Eve", "age": None}
eve1b = Person(name=b"Eve", age=None)
# TODO: these should be errors (missing required key)
eve2a: Person = {"age": 22}
eve2b = Person(age=22)
# TODO: these should be errors (additional key)
eve3a: Person = {"name": "Eve", "age": 25, "extra": True}
eve3b = Person(name="Eve", age=25, extra=True)
```
Assignments to keys are also validated:
```py
# TODO: this should be an error
alice["name"] = None
```
Assignments to non-existing keys are disallowed:
```py
# TODO: this should be an error
alice["extra"] = True
```
## Structural assignability
Assignability between `TypedDict` types is structural, that is, it is based on the presence of keys
and their types, rather than the class hierarchy:
```py
from typing import TypedDict
class Person(TypedDict):
name: str
class Employee(TypedDict):
name: str
employee_id: int
p1: Person = Employee(name="Alice", employee_id=1)
# TODO: this should be an error
e1: Employee = Person(name="Eve")
```
All typed dictionaries can be assigned to `Mapping[str, object]`:
```py
from typing import Mapping, TypedDict
class Person(TypedDict):
name: str
age: int | None
alice: Person = {"name": "Alice", "age": 30}
m: Mapping[str, object] = Person(name="Alice", age=30)
```
They can *not* be assigned to `dict[str, object]`, as that would allow them to be mutated in unsafe
ways:
```py
from typing import TypedDict
def dangerous(d: dict[str, object]) -> None:
d["name"] = 1
class Person(TypedDict):
name: str
alice: Person = {"name": "Alice"}
# TODO: this should be an invalid-assignment error
dangerous(alice)
# TODO: this should be `str`
reveal_type(alice["name"]) # revealed: Unknown
```
## Types of keys and values
```py
from typing import TypedDict
class Person(TypedDict):
name: str
age: int | None
def _(p: Person) -> None:
reveal_type(p.keys()) # revealed: @Todo(Support for `TypedDict`)
reveal_type(p.values()) # revealed: @Todo(Support for `TypedDict`)
```
## Unlike normal classes
`TypedDict` types are not like normal classes. The "attributes" can not be accessed. Neither on the
class itself, nor on inhabitants of the type defined by the class:
```py
from typing import TypedDict
class Person(TypedDict):
name: str
age: int | None
# TODO: this should be an error
Person.name
# TODO: this should be an error
Person(name="Alice", age=30).name
```
## Special properties
`TypedDict` class definitions have some special properties that can be used for introspection:
```py
from typing import TypedDict
class Person(TypedDict):
name: str
age: int | None
reveal_type(Person.__total__) # revealed: @Todo(Support for `TypedDict`)
reveal_type(Person.__required_keys__) # revealed: @Todo(Support for `TypedDict`)
reveal_type(Person.__optional_keys__) # revealed: @Todo(Support for `TypedDict`)
```
## Subclassing
`TypedDict` types can be subclassed. The subclass can add new keys:
```py
from typing import TypedDict
class Person(TypedDict):
name: str
class Employee(Person):
employee_id: int
alice: Employee = {"name": "Alice", "employee_id": 1}
# TODO: this should be an error (missing required key)
eve: Employee = {"name": "Eve"}
```
## Generic `TypedDict`
`TypedDict`s can also be generic.
### Legacy generics
```py
from typing import Generic, TypeVar, TypedDict
T = TypeVar("T")
class TaggedData(TypedDict, Generic[T]):
data: T
tag: str
p1: TaggedData[int] = {"data": 42, "tag": "number"}
p2: TaggedData[str] = {"data": "Hello", "tag": "text"}
# TODO: this should be an error (type mismatch)
p3: TaggedData[int] = {"data": "not a number", "tag": "number"}
```
### PEP-695 generics
```toml
[environment]
python-version = "3.12"
```
```py
from typing import TypedDict
class TaggedData[T](TypedDict):
data: T
tag: str
p1: TaggedData[int] = {"data": 42, "tag": "number"}
p2: TaggedData[str] = {"data": "Hello", "tag": "text"}
# TODO: this should be an error (type mismatch)
p3: TaggedData[int] = {"data": "not a number", "tag": "number"}
```
## Recursive `TypedDict`
`TypedDict`s can also be recursive, allowing for nested structures:
```py
from __future__ import annotations
from typing import TypedDict
class Node(TypedDict):
name: str
parent: Node | None
root: Node = {"name": "root", "parent": None}
child: Node = {"name": "child", "parent": root}
grandchild: Node = {"name": "grandchild", "parent": child}
nested: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": "n3", "parent": None}}}
# TODO: this should be an error (invalid type for `name` in innermost node)
nested_invalid: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": 3, "parent": None}}}
```
## Function/assignment syntax
This is not yet supported. Make sure that we do not emit false positives for this syntax:
```py
from typing_extensions import TypedDict, Required
# Alternative syntax
Message = TypedDict("Message", {"id": Required[int], "content": str}, total=False)
@ -20,6 +271,7 @@ msg = Message(id=1, content="Hello")
# No errors for yet-unsupported features (`closed`):
OtherMessage = TypedDict("OtherMessage", {"id": int, "content": str}, closed=True)
reveal_type(Person.__required_keys__) # revealed: @Todo(Support for `TypedDict`)
reveal_type(Message.__required_keys__) # revealed: @Todo(Support for `TypedDict`)
```
[`typeddict`]: https://typing.python.org/en/latest/spec/typeddict.html