[ty] Represent NamedTuple as an opaque special form, not a class (#19915)

This commit is contained in:
Alex Waygood 2025-08-15 18:20:14 +01:00 committed by GitHub
parent 9ced219ffc
commit 26d6c3831f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 182 additions and 48 deletions

View file

@ -268,6 +268,88 @@ alice = Person(1, "Alice", 42)
bob = Person(2, "Bob")
```
## The symbol `NamedTuple` itself
At runtime, `NamedTuple` is a function, and we understand this:
```py
import types
import typing
def expects_functiontype(x: types.FunctionType): ...
expects_functiontype(typing.NamedTuple)
```
This means we also understand that all attributes on function objects are available on the symbol
`typing.NamedTuple`:
```py
reveal_type(typing.NamedTuple.__name__) # revealed: str
reveal_type(typing.NamedTuple.__qualname__) # revealed: str
reveal_type(typing.NamedTuple.__kwdefaults__) # revealed: dict[str, Any] | None
# TODO: this should cause us to emit a diagnostic and reveal `Unknown` (function objects don't have an `__mro__` attribute),
# but the fact that we don't isn't actually a `NamedTuple` bug (https://github.com/astral-sh/ty/issues/986)
reveal_type(typing.NamedTuple.__mro__) # revealed: tuple[<class 'FunctionType'>, <class 'object'>]
```
By the normal rules, `NamedTuple` and `type[NamedTuple]` should not be valid in type expressions --
there is no object at runtime that is an "instance of `NamedTuple`", nor is there any class at
runtime that is a "subclass of `NamedTuple`" -- these are both impossible, since `NamedTuple` is a
function and not a class. However, for compatibility with other type checkers, we allow `NamedTuple`
in type expressions and understand it as describing an interface that all `NamedTuple` classes would
satisfy:
```py
def expects_named_tuple(x: typing.NamedTuple):
reveal_type(x) # revealed: tuple[object, ...] & NamedTupleLike
reveal_type(x._make) # revealed: bound method type[NamedTupleLike]._make(iterable: Iterable[Any]) -> Self@_make
reveal_type(x._replace) # revealed: bound method NamedTupleLike._replace(**kwargs) -> Self@_replace
# revealed: Overload[(value: tuple[object, ...], /) -> tuple[object, ...], (value: tuple[_T@__add__, ...], /) -> tuple[object, ...]]
reveal_type(x.__add__)
reveal_type(x.__iter__) # revealed: bound method tuple[object, ...].__iter__() -> Iterator[object]
def _(y: type[typing.NamedTuple]):
reveal_type(y) # revealed: @Todo(unsupported type[X] special form)
```
Any instance of a `NamedTuple` class can therefore be passed for a function parameter that is
annotated with `NamedTuple`:
```py
from typing import NamedTuple, Protocol, Iterable, Any
from ty_extensions import static_assert, is_assignable_to
class Point(NamedTuple):
x: int
y: int
reveal_type(Point._make) # revealed: bound method <class 'Point'>._make(iterable: Iterable[Any]) -> Self@_make
reveal_type(Point._asdict) # revealed: def _asdict(self) -> dict[str, Any]
reveal_type(Point._replace) # revealed: def _replace(self, **kwargs: Any) -> Self@_replace
static_assert(is_assignable_to(Point, NamedTuple))
expects_named_tuple(Point(x=42, y=56)) # fine
# error: [invalid-argument-type] "Argument to function `expects_named_tuple` is incorrect: Expected `tuple[object, ...] & NamedTupleLike`, found `tuple[Literal[1], Literal[2]]`"
expects_named_tuple((1, 2))
```
The type described by `NamedTuple` in type expressions is understood as being assignable to
`tuple[object, ...]` and `tuple[Any, ...]`:
```py
static_assert(is_assignable_to(NamedTuple, tuple))
static_assert(is_assignable_to(NamedTuple, tuple[object, ...]))
static_assert(is_assignable_to(NamedTuple, tuple[Any, ...]))
def expects_tuple(x: tuple[object, ...]): ...
def _(x: NamedTuple):
expects_tuple(x) # fine
```
## NamedTuple with custom `__getattr__`
This is a regression test for <https://github.com/astral-sh/ty/issues/322>. Make sure that the