[ty] prefer declared type if mutually assignable (#24802)

This commit is contained in:
Carl Meyer
2026-04-27 19:34:42 -07:00
committed by GitHub
parent d06604fce6
commit 87443c22d4
6 changed files with 123 additions and 31 deletions
@@ -251,6 +251,46 @@ a: list[str] = [1, 2, 3]
b: set[int] = {1, 2, "3"}
```
## Mutually assignable annotated assignments use the declared type
When an annotated assignment has a value whose inferred type is assignable to the declared type, the
binding uses the declared type if the declared type is also assignable back to the inferred type.
This indicates that we are dealing with difference in precision (graduality) rather than a narrowed
static type; in that case we want to prefer the user's annotation.
The actual inferred type of the right-hand side is still used to validate the assignment.
```py
from typing import Any
def returns_list_any() -> list[Any]:
return [1]
def returns_list_int() -> list[int]:
return [1]
def returns_any() -> Any:
return 1
v1: Any = 1
reveal_type(v1) # revealed: Any
v2: int = returns_any()
reveal_type(v2) # revealed: int
v3: list[Any] = returns_list_int()
reveal_type(v3) # revealed: list[Any]
v4: list[int] = returns_list_any()
reveal_type(v4) # revealed: list[int]
v4: object = returns_list_int()
reveal_type(v4) # revealed: list[int]
# error: [invalid-assignment] "Object of type `list[int]` is not assignable to `list[str]`"
invalid: list[str] = returns_list_int()
```
## Generic constructor annotations are understood
```toml
@@ -531,28 +571,28 @@ class TD2(TypedDict):
def _(dt: dict[str, Any], key: str):
x1: TD = dt.get(key, {})
reveal_type(x1) # revealed: Any
reveal_type(x1) # revealed: TD
x2: TD = dt.get(key, {"x": 0})
reveal_type(x2) # revealed: Any
reveal_type(x2) # revealed: TD
x3: TD | None = dt.get(key, {})
reveal_type(x3) # revealed: Any
reveal_type(x3) # revealed: TD | None
x4: TD | None = dt.get(key, {"x": 0})
reveal_type(x4) # revealed: Any
reveal_type(x4) # revealed: TD | None
x5: TD2 = dt.get(key, {})
reveal_type(x5) # revealed: Any
reveal_type(x5) # revealed: TD2
x6: TD2 = dt.get(key, {"x": 0})
reveal_type(x6) # revealed: Any
reveal_type(x6) # revealed: TD2
x7: TD2 | None = dt.get(key, {})
reveal_type(x7) # revealed: Any
reveal_type(x7) # revealed: TD2 | None
x8: TD2 | None = dt.get(key, {"x": 0})
reveal_type(x8) # revealed: Any
reveal_type(x8) # revealed: TD2 | None
```
Partially specialized type context is not ignored:
@@ -642,11 +682,12 @@ f: list[Any] | None = f2(1)
reveal_type(f) # revealed: list[Any] | None
g: list[Any] | dict[Any, Any] = f3(1)
# TODO: Better constraint solver.
reveal_type(g) # revealed: list[int] | dict[int, int]
reveal_type(g) # revealed: list[Any] | dict[Any, Any]
```
We only prefer the declared type if it is in non-covariant position.
When inferring a generic call, we only use the declared type as type context if it is in
non-covariant position. The final annotated assignment binding still uses the declared type if the
inferred and declared types are mutually assignable.
```py
class Bivariant[T]:
@@ -685,15 +726,25 @@ reveal_type(x2) # revealed: Covariant[Literal[1]]
reveal_type(x3) # revealed: Contravariant[int]
reveal_type(x4) # revealed: Invariant[int]
x5: Bivariant[Any] = bivariant(1)
x6: Covariant[Any] = covariant(1)
x7: Contravariant[Any] = contravariant(1)
x8: Invariant[Any] = invariant(1)
x5: Bivariant[int | None] = bivariant(1)
x6: Covariant[int | None] = covariant(1)
x7: Contravariant[int | None] = contravariant(1)
x8: Invariant[int | None] = invariant(1)
reveal_type(x5) # revealed: Bivariant[Literal[1]]
reveal_type(x5) # revealed: Bivariant[int | None]
reveal_type(x6) # revealed: Covariant[Literal[1]]
reveal_type(x7) # revealed: Contravariant[Any]
reveal_type(x8) # revealed: Invariant[Any]
reveal_type(x7) # revealed: Contravariant[int | None]
reveal_type(x8) # revealed: Invariant[int | None]
x9: Bivariant[Any] = bivariant(1)
x10: Covariant[Any] = covariant(1)
x11: Contravariant[Any] = contravariant(1)
x12: Invariant[Any] = invariant(1)
reveal_type(x9) # revealed: Bivariant[Any]
reveal_type(x10) # revealed: Covariant[Any]
reveal_type(x11) # revealed: Contravariant[Any]
reveal_type(x12) # revealed: Invariant[Any]
```
```py
@@ -35,8 +35,7 @@ def _(l: list[int] | None = None):
reveal_type(l1) # revealed: (list[int] & ~AlwaysFalsy) | list[Unknown]
l2: list[int] = l or list()
# it would be better if this were `list[int]`? (https://github.com/astral-sh/ty/issues/136)
reveal_type(l2) # revealed: (list[int] & ~AlwaysFalsy) | list[Unknown]
reveal_type(l2) # revealed: list[int]
def f[T](x: T, cond: bool) -> T | list[T]:
return x if cond else [x]
@@ -29,6 +29,9 @@ In particular, we should raise errors in the "possibly-undeclared-and-unbound" a
| possibly-unbound | | `possibly-missing-import` | ? |
| unbound | | ? | `unresolved-import` |
When the declared and inferred types are mutually assignable, we use `T_declared` directly instead
of unioning it with `T_inferred`.
## Declared
### Declared and bound
@@ -137,8 +140,7 @@ Public.a = None
If a symbol is possibly undeclared and possibly unbound, we also use the union of the declared and
inferred types. This case is interesting because the "possibly declared" definition might not be the
same as the "possibly bound" definition (symbol `b`). Note that we raise a `possibly-missing-import`
error for both `a` and `b`:
same as the "possibly bound" definition:
```py
from typing import Any
@@ -148,13 +150,10 @@ def flag() -> bool:
class Public:
if flag():
a: Any = 1
b = 2
else:
b: str
# error: [possibly-missing-attribute]
reveal_type(Public.a) # revealed: Literal[1] | Any
# error: [possibly-missing-attribute]
reveal_type(Public.b) # revealed: Literal[2] | str
@@ -23,6 +23,31 @@ alice.role = "moderator"
bob = Member(name="Bob", tag="VIP")
```
## `Any` annotations preserve field specifier metadata
Even when a field is explicitly annotated as `Any`, `field(...)` should still be recognized as a
dataclass field specifier. The synthesized field metadata comes from the right-hand side, not from
the declared type.
```py
from dataclasses import dataclass, field
from typing import Any
@dataclass
class AnyFieldSpecifier:
proto: Any = field(repr=False)
key: str | None
reveal_type(AnyFieldSpecifier.__init__) # revealed: (self: AnyFieldSpecifier, proto: Any, key: str | None) -> None
@dataclass
class AnyInitFalseField:
key: str | None
proto: Any = field(init=False)
reveal_type(AnyInitFalseField.__init__) # revealed: (self: AnyInitFalseField, key: str | None) -> None
```
## Inheritance with defaults
```py
@@ -246,7 +246,7 @@ x11: list[Literal[1] | Literal[2] | Literal[3]] = [1, 2, 3]
reveal_type(x11) # revealed: list[Literal[1, 2, 3]]
x12: Y[Y[Literal[1]]] = [[1]]
reveal_type(x12) # revealed: list[list[Literal[1]]]
reveal_type(x12) # revealed: list[Y[Literal[1]]]
x13: list[tuple[Literal[1], Literal[2], Literal[3]]] = [(1, 2, 3)]
reveal_type(x13) # revealed: list[tuple[Literal[1], Literal[2], Literal[3]]]
@@ -459,7 +459,7 @@ type X = Literal["hello"]
x4: X = "hello"
reveal_type(x4) # revealed: Literal["hello"]
reveal_type([x4]) # revealed: list[Literal["hello"]]
reveal_type([x4]) # revealed: list[X]
class MyEnum(Enum):
A = 1
@@ -165,6 +165,12 @@ impl<'db> DeclaredAndInferredType<'db> {
}
}
fn should_preserve_inferred_binding_type(ty: Type<'_>) -> bool {
// Dataclass field specifiers carry metadata in the inferred RHS type; replacing it with the
// declared field type would lose settings like `init=False`.
matches!(ty, Type::KnownInstance(KnownInstanceType::Field(_)))
}
/// We currently store one dataclass field-specifiers inline, because that covers standard
/// dataclasses. attrs uses 2 specifiers, pydantic and strawberry use 3 specifiers. SQLAlchemy
/// uses 7 field specifiers. We could probably store more inline if this turns out to be a
@@ -1345,19 +1351,31 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
}
}
if inferred_ty.is_assignable_to(self.db(), declared_ty.inner_type()) {
(declared_ty, inferred_ty)
let declared_type = declared_ty.inner_type();
if inferred_ty.is_assignable_to(self.db(), declared_type) {
if !should_preserve_inferred_binding_type(inferred_ty)
// TODO We currently can't distinguish here between "no declared type" and
// "declared types is `Unknown` (e.g. due to a bad annotation, missing
// import, etc.)". Ideally we would still prefer `Unknown` declared type,
// but use inferred type if there is no declared type.
&& !matches!(declared_type, Type::Dynamic(DynamicType::Unknown))
&& declared_type.is_assignable_to(self.db(), inferred_ty)
{
(declared_ty, declared_type)
} else {
(declared_ty, inferred_ty)
}
} else {
report_invalid_assignment(
&self.context,
node,
definition,
declared_ty.inner_type(),
declared_type,
inferred_ty,
);
// if the assignment is invalid, fall back to assuming the annotation is correct
(declared_ty, declared_ty.inner_type())
(declared_ty, declared_type)
}
}
};