[ty] Use annotated parameters as type context (#20635)
Some checks are pending
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / Determine changes (push) Waiting to run
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks instrumented (ruff) (push) Blocked by required conditions
CI / benchmarks instrumented (ty) (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

## Summary

Use the type annotation of function parameters as bidirectional type
context when inferring the argument expression. For example, the
following example now type-checks:

```py
class TD(TypedDict):
    x: int

def f(_: TD): ...

f({ "x": 1 })
```

Part of https://github.com/astral-sh/ty/issues/168.
This commit is contained in:
Ibraheem Ahmed 2025-10-03 17:14:51 -04:00 committed by GitHub
parent b83ac5e234
commit 2ce3aba458
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 519 additions and 95 deletions

View file

@ -1662,3 +1662,67 @@ def _(arg: tuple[A | B, Any]):
reveal_type(f(arg)) # revealed: Unknown
reveal_type(f(*(arg,))) # revealed: Unknown
```
## Bidirectional Type Inference
```toml
[environment]
python-version = "3.12"
```
Type inference accounts for parameter type annotations across all overloads.
```py
from typing import TypedDict, overload
class T(TypedDict):
x: int
@overload
def f(a: list[T], b: int) -> int: ...
@overload
def f(a: list[dict[str, int]], b: str) -> str: ...
def f(a: list[dict[str, int]] | list[T], b: int | str) -> int | str:
return 1
def int_or_str() -> int | str:
return 1
x = f([{"x": 1}], int_or_str())
reveal_type(x) # revealed: int | str
# TODO: error: [no-matching-overload] "No overload of function `f` matches arguments"
# we currently incorrectly consider `list[dict[str, int]]` a subtype of `list[T]`
f([{"y": 1}], int_or_str())
```
Non-matching overloads do not produce diagnostics:
```py
from typing import TypedDict, overload
class T(TypedDict):
x: int
@overload
def f(a: T, b: int) -> int: ...
@overload
def f(a: dict[str, int], b: str) -> str: ...
def f(a: T | dict[str, int], b: int | str) -> int | str:
return 1
x = f({"y": 1}, "a")
reveal_type(x) # revealed: str
```
```py
from typing import SupportsRound, overload
@overload
def takes_str_or_float(x: str): ...
@overload
def takes_str_or_float(x: float): ...
def takes_str_or_float(x: float | str): ...
takes_str_or_float(round(1.0))
```

View file

@ -251,3 +251,59 @@ from ty_extensions import Intersection, Not
def _(x: Union[Intersection[Any, Not[int]], Intersection[Any, Not[int]]]):
reveal_type(x) # revealed: Any & ~int
```
## Bidirectional Type Inference
```toml
[environment]
python-version = "3.12"
```
Type inference accounts for parameter type annotations across all signatures in a union.
```py
from typing import TypedDict, overload
class T(TypedDict):
x: int
def _(flag: bool):
if flag:
def f(x: T) -> int:
return 1
else:
def f(x: dict[str, int]) -> int:
return 1
x = f({"x": 1})
reveal_type(x) # revealed: int
# TODO: error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `T`, found `dict[str, int]`"
# we currently consider `TypedDict` instances to be subtypes of `dict`
f({"y": 1})
```
Diagnostics unrelated to the type-context are only reported once:
```py
def f[T](x: T) -> list[T]:
return [x]
def a(x: list[bool], y: list[bool]): ...
def b(x: list[int], y: list[int]): ...
def c(x: list[int], y: list[int]): ...
def _(x: int):
if x == 0:
y = a
elif x == 1:
y = b
else:
y = c
if x == 0:
z = True
y(f(True), [True])
# error: [possibly-unresolved-reference] "Name `z` used when possibly not defined"
y(f(True), [z])
```

View file

@ -10,6 +10,7 @@ from typing_extensions import assert_type
def _(x: int):
assert_type(x, int) # fine
assert_type(x, str) # error: [type-assertion-failure]
assert_type(assert_type(x, int), int)
```
## Narrowing

View file

@ -17,6 +17,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/directives/assert_type.m
3 | def _(x: int):
4 | assert_type(x, int) # fine
5 | assert_type(x, str) # error: [type-assertion-failure]
6 | assert_type(assert_type(x, int), int)
```
# Diagnostics
@ -31,6 +32,7 @@ error[type-assertion-failure]: Argument does not have asserted type `str`
| ^^^^^^^^^^^^-^^^^^^
| |
| Inferred type of argument is `int`
6 | assert_type(assert_type(x, int), int)
|
info: `str` and `int` are not equivalent types
info: rule `type-assertion-failure` is enabled by default

View file

@ -152,7 +152,7 @@ Person(name="Alice")
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
Person({"name": "Alice"})
# TODO: this should be an error, similar to the above
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
accepts_person({"name": "Alice"})
# TODO: this should be an error, similar to the above
house.owner = {"name": "Alice"}
@ -171,7 +171,7 @@ Person(name=None, age=30)
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
Person({"name": None, "age": 30})
# TODO: this should be an error, similar to the above
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
accepts_person({"name": None, "age": 30})
# TODO: this should be an error, similar to the above
house.owner = {"name": None, "age": 30}
@ -190,7 +190,7 @@ Person(name="Alice", age=30, extra=True)
# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra""
Person({"name": "Alice", "age": 30, "extra": True})
# TODO: this should be an error
# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra""
accepts_person({"name": "Alice", "age": 30, "extra": True})
# TODO: this should be an error
house.owner = {"name": "Alice", "age": 30, "extra": True}