[ty] Default-specialization of generic type aliases (#21765)

## Summary

Implement default-specialization of generic type aliases (implicit or
PEP-613) if they are used in a type expression without an explicit
specialization.

closes https://github.com/astral-sh/ty/issues/1690

## Typing conformance

```diff
-generics_defaults_specialization.py:26:5: error[type-assertion-failure] Type `SomethingWithNoDefaults[int, str]` does not match asserted type `SomethingWithNoDefaults[int, DefaultStrT]`
```

That's exactly what we want ✔️ 

All other tests in this file pass as well, with the exception of this
assertion, which is just wrong (at least according to our
interpretation, `type[Bar] != <class 'Bar'>`). I checked that we do
correctly default-specialize the type parameter which is not displayed
in the diagnostic that we raise.
```py
class Bar(SubclassMe[int, DefaultStrT]): ...

assert_type(Bar, type[Bar[str]])  # ty: Type `type[Bar[str]]` does not match asserted type `<class 'Bar'>`
```

## Ecosystem impact

Looks like I should have included this last week 😎 

## Test Plan

Updated pre-existing tests and add a few new ones.
This commit is contained in:
David Peter 2025-12-03 09:10:45 +01:00 committed by GitHub
parent c5b8d551df
commit e6ddeed386
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 49 additions and 43 deletions

View File

@ -190,14 +190,10 @@ def _(
reveal_type(type_of_str_or_int) # revealed: type[str] | int
reveal_type(int_or_callable) # revealed: int | ((str, /) -> bytes)
reveal_type(callable_or_int) # revealed: ((str, /) -> bytes) | int
# TODO should be Unknown | int
reveal_type(type_var_or_int) # revealed: T@TypeVarOrInt | int
# TODO should be int | Unknown
reveal_type(int_or_type_var) # revealed: int | T@IntOrTypeVar
# TODO should be Unknown | None
reveal_type(type_var_or_none) # revealed: T@TypeVarOrNone | None
# TODO should be None | Unknown
reveal_type(none_or_type_var) # revealed: None | T@NoneOrTypeVar
reveal_type(type_var_or_int) # revealed: Unknown | int
reveal_type(int_or_type_var) # revealed: int | Unknown
reveal_type(type_var_or_none) # revealed: Unknown | None
reveal_type(none_or_type_var) # revealed: None | Unknown
```
If a type is unioned with itself in a value expression, the result is just that type. No
@ -529,28 +525,18 @@ def _(
annotated_unknown: AnnotatedType,
optional_unknown: MyOptional,
):
# TODO: This should be `list[Unknown]`
reveal_type(list_unknown) # revealed: list[T@MyList]
# TODO: This should be `dict[Unknown, Unknown]`
reveal_type(dict_unknown) # revealed: dict[T@MyDict, U@MyDict]
# TODO: Should be `type[Unknown]`
reveal_type(subclass_of_unknown) # revealed: type[T@MyType]
# TODO: Should be `tuple[int, Unknown]`
reveal_type(int_and_unknown) # revealed: tuple[int, T@IntAndType]
# TODO: Should be `tuple[Unknown, Unknown]`
reveal_type(pair_of_unknown) # revealed: tuple[T@Pair, T@Pair]
# TODO: Should be `tuple[Unknown, Unknown]`
reveal_type(unknown_and_unknown) # revealed: tuple[T@Sum, U@Sum]
# TODO: Should be `list[Unknown] | tuple[Unknown, ...]`
reveal_type(list_or_tuple) # revealed: list[T@ListOrTuple] | tuple[T@ListOrTuple, ...]
# TODO: Should be `list[Unknown] | tuple[Unknown, ...]`
reveal_type(list_or_tuple_legacy) # revealed: list[T@ListOrTupleLegacy] | tuple[T@ListOrTupleLegacy, ...]
# TODO: Should be `(...) -> Unknown`
reveal_type(list_unknown) # revealed: list[Unknown]
reveal_type(dict_unknown) # revealed: dict[Unknown, Unknown]
reveal_type(subclass_of_unknown) # revealed: type[Unknown]
reveal_type(int_and_unknown) # revealed: tuple[int, Unknown]
reveal_type(pair_of_unknown) # revealed: tuple[Unknown, Unknown]
reveal_type(unknown_and_unknown) # revealed: tuple[Unknown, Unknown]
reveal_type(list_or_tuple) # revealed: list[Unknown] | tuple[Unknown, ...]
reveal_type(list_or_tuple_legacy) # revealed: list[Unknown] | tuple[Unknown, ...]
# TODO: should be (...) -> Unknown
reveal_type(my_callable) # revealed: @Todo(Callable[..] specialized with ParamSpec)
# TODO: Should be `Unknown`
reveal_type(annotated_unknown) # revealed: T@AnnotatedType
# TODO: Should be `Unknown | None`
reveal_type(optional_unknown) # revealed: T@MyOptional | None
reveal_type(annotated_unknown) # revealed: Unknown
reveal_type(optional_unknown) # revealed: Unknown | None
```
For a type variable with a default, we use the default type:
@ -563,10 +549,13 @@ MyListWithDefault = list[T_default]
def _(
list_of_str: MyListWithDefault[str],
list_of_int: MyListWithDefault,
list_of_str_or_none: MyListWithDefault[str] | None,
list_of_int_or_none: MyListWithDefault | None,
):
reveal_type(list_of_str) # revealed: list[str]
# TODO: this should be `list[int]`
reveal_type(list_of_int) # revealed: list[T_default@MyListWithDefault]
reveal_type(list_of_int) # revealed: list[int]
reveal_type(list_of_str_or_none) # revealed: list[str] | None
reveal_type(list_of_int_or_none) # revealed: list[int] | None
```
(Generic) implicit type aliases can be used as base classes:
@ -601,7 +590,7 @@ Generic implicit type aliases can be imported from other modules and specialized
```py
from typing_extensions import TypeVar
T = TypeVar("T")
T = TypeVar("T", default=str)
MyList = list[T]
```
@ -615,9 +604,13 @@ import my_types as mt
def _(
list_of_ints1: MyList[int],
list_of_ints2: mt.MyList[int],
list_of_str: mt.MyList,
list_of_str_or_none: mt.MyList | None,
):
reveal_type(list_of_ints1) # revealed: list[int]
reveal_type(list_of_ints2) # revealed: list[int]
reveal_type(list_of_str) # revealed: list[str]
reveal_type(list_of_str_or_none) # revealed: list[str] | None
```
### In stringified annotations

View File

@ -8263,6 +8263,16 @@ impl<'db> Type<'db> {
_ => None,
}
}
/// Default-specialize all legacy typevars in this type.
///
/// This is used when an implicit type alias is referenced without explicitly specializing it.
pub(crate) fn default_specialize(self, db: &'db dyn Db) -> Type<'db> {
let mut variables = FxOrderSet::default();
self.find_legacy_typevars(db, None, &mut variables);
let generic_context = GenericContext::from_typevar_instances(db, variables);
self.apply_specialization(db, generic_context.default_specialization(db, None))
}
}
impl<'db> From<&Type<'db>> for Type<'db> {

View File

@ -144,18 +144,19 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
)
}
_ => TypeAndQualifiers::declared(
ty.in_type_expression(
builder.db(),
builder.scope(),
builder.typevar_binding_context,
)
.unwrap_or_else(|error| {
error.into_fallback_type(
&builder.context,
annotation,
builder.is_reachable(annotation),
ty.default_specialize(builder.db())
.in_type_expression(
builder.db(),
builder.scope(),
builder.typevar_binding_context,
)
}),
.unwrap_or_else(|error| {
error.into_fallback_type(
&builder.context,
annotation,
builder.is_reachable(annotation),
)
}),
),
}
}

View File

@ -91,6 +91,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
ast::Expr::Name(name) => match name.ctx {
ast::ExprContext::Load => self
.infer_name_expression(name)
.default_specialize(self.db())
.in_type_expression(self.db(), self.scope(), self.typevar_binding_context)
.unwrap_or_else(|error| {
error.into_fallback_type(
@ -108,6 +109,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
ast::Expr::Attribute(attribute_expression) => match attribute_expression.ctx {
ast::ExprContext::Load => self
.infer_attribute_expression(attribute_expression)
.default_specialize(self.db())
.in_type_expression(self.db(), self.scope(), self.typevar_binding_context)
.unwrap_or_else(|error| {
error.into_fallback_type(