[ty] Narrow specialized generics using isinstance() (#20256)

Closes astral-sh/ty#456. Part of astral-sh/ty#994.

After all the foundational work, this is only a small change, but let's
see if it exposes any unresolved issues.
This commit is contained in:
Jelle Zijlstra 2025-09-04 15:28:33 -07:00 committed by GitHub
parent 670fffef37
commit 08c1d3660c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 106 additions and 11 deletions

View file

@ -281,12 +281,10 @@ def if_else_exhaustive(x: A[D] | B[E] | C[F]):
elif isinstance(x, C): elif isinstance(x, C):
pass pass
else: else:
# TODO: both of these are false positives (https://github.com/astral-sh/ty/issues/456) no_diagnostic_here
no_diagnostic_here # error: [unresolved-reference] assert_never(x)
assert_never(x) # error: [type-assertion-failure]
# TODO: false-positive diagnostic (https://github.com/astral-sh/ty/issues/456) def if_else_exhaustive_no_assertion(x: A[D] | B[E] | C[F]) -> int:
def if_else_exhaustive_no_assertion(x: A[D] | B[E] | C[F]) -> int: # error: [invalid-return-type]
if isinstance(x, A): if isinstance(x, A):
return 0 return 0
elif isinstance(x, B): elif isinstance(x, B):

View file

@ -308,3 +308,88 @@ def i[T: Intersection[type[Bar], type[Baz | Spam]], U: (type[Eggs], type[Ham])](
return (y, z) return (y, z)
``` ```
## Narrowing with generics
```toml
[environment]
python-version = "3.12"
```
Narrowing to a generic class using `isinstance()` uses the top materialization of the generic. With
a covariant generic, this is equivalent to using the upper bound of the type parameter (by default,
`object`):
```py
class Covariant[T]:
def get(self) -> T:
raise NotImplementedError
def _(x: object):
if isinstance(x, Covariant):
reveal_type(x) # revealed: Covariant[object]
reveal_type(x.get()) # revealed: object
```
Similarly, contravariant type parameters use their lower bound of `Never`:
```py
class Contravariant[T]:
def push(self, x: T) -> None: ...
def _(x: object):
if isinstance(x, Contravariant):
reveal_type(x) # revealed: Contravariant[Never]
# error: [invalid-argument-type] "Argument to bound method `push` is incorrect: Expected `Never`, found `Literal[42]`"
x.push(42)
```
Invariant generics are trickiest. The top materialization, conceptually the type that includes all
instances of the generic class regardless of the type parameter, cannot be represented directly in
the type system, so we represent it with the internal `Top[]` special form.
```py
class Invariant[T]:
def push(self, x: T) -> None: ...
def get(self) -> T:
raise NotImplementedError
def _(x: object):
if isinstance(x, Invariant):
reveal_type(x) # revealed: Top[Invariant[Unknown]]
reveal_type(x.get()) # revealed: object
# error: [invalid-argument-type] "Argument to bound method `push` is incorrect: Expected `Never`, found `Literal[42]`"
x.push(42)
```
When more complex types are involved, the `Top[]` type may get simplified away.
```py
def _(x: list[int] | set[str]):
if isinstance(x, list):
reveal_type(x) # revealed: list[int]
else:
reveal_type(x) # revealed: set[str]
```
Though if the types involved are not disjoint bases, we necessarily keep a more complex type.
```py
def _(x: Invariant[int] | Covariant[str]):
if isinstance(x, Invariant):
reveal_type(x) # revealed: Invariant[int] | (Covariant[str] & Top[Invariant[Unknown]])
else:
reveal_type(x) # revealed: Covariant[str] & ~Top[Invariant[Unknown]]
```
The behavior of `issubclass()` is similar.
```py
def _(x: type[object], y: type[object], z: type[object]):
if issubclass(x, Covariant):
reveal_type(x) # revealed: type[Covariant[object]]
if issubclass(y, Contravariant):
reveal_type(y) # revealed: type[Contravariant[Never]]
if issubclass(z, Invariant):
reveal_type(z) # revealed: type[Top[Invariant[Unknown]]]
```

View file

@ -29,10 +29,10 @@ use crate::types::typed_dict::typed_dict_params_from_class_def;
use crate::types::{ use crate::types::{
ApplyTypeMappingVisitor, Binding, BoundSuperError, BoundSuperType, CallableType, ApplyTypeMappingVisitor, Binding, BoundSuperError, BoundSuperType, CallableType,
DataclassParams, DeprecatedInstance, FindLegacyTypeVarsVisitor, HasRelationToVisitor, DataclassParams, DeprecatedInstance, FindLegacyTypeVarsVisitor, HasRelationToVisitor,
IsEquivalentVisitor, KnownInstanceType, ManualPEP695TypeAliasType, NormalizedVisitor, IsEquivalentVisitor, KnownInstanceType, ManualPEP695TypeAliasType, MaterializationKind,
PropertyInstanceType, StringLiteralType, TypeAliasType, TypeMapping, TypeRelation, NormalizedVisitor, PropertyInstanceType, StringLiteralType, TypeAliasType, TypeMapping,
TypeVarBoundOrConstraints, TypeVarInstance, TypeVarKind, TypedDictParams, UnionBuilder, TypeRelation, TypeVarBoundOrConstraints, TypeVarInstance, TypeVarKind, TypedDictParams,
VarianceInferable, declaration_type, infer_definition_types, UnionBuilder, VarianceInferable, declaration_type, infer_definition_types,
}; };
use crate::{ use crate::{
Db, FxIndexMap, FxOrderSet, Program, Db, FxIndexMap, FxOrderSet, Program,
@ -1470,6 +1470,18 @@ impl<'db> ClassLiteral<'db> {
}) })
} }
pub(crate) fn top_materialization(self, db: &'db dyn Db) -> ClassType<'db> {
self.apply_specialization(db, |generic_context| {
generic_context
.default_specialization(db, self.known(db))
.materialize_impl(
db,
MaterializationKind::Top,
&ApplyTypeMappingVisitor::default(),
)
})
}
/// Returns the default specialization of this class. For non-generic classes, the class is /// Returns the default specialization of this class. For non-generic classes, the class is
/// returned unchanged. For a non-specialized generic class, we return a generic alias that /// returned unchanged. For a non-specialized generic class, we return a generic alias that
/// applies the default specialization to the class's typevars. /// applies the default specialization to the class's typevars.

View file

@ -174,10 +174,10 @@ impl ClassInfoConstraintFunction {
fn generate_constraint<'db>(self, db: &'db dyn Db, classinfo: Type<'db>) -> Option<Type<'db>> { fn generate_constraint<'db>(self, db: &'db dyn Db, classinfo: Type<'db>) -> Option<Type<'db>> {
let constraint_fn = |class: ClassLiteral<'db>| match self { let constraint_fn = |class: ClassLiteral<'db>| match self {
ClassInfoConstraintFunction::IsInstance => { ClassInfoConstraintFunction::IsInstance => {
Type::instance(db, class.default_specialization(db)) Type::instance(db, class.top_materialization(db))
} }
ClassInfoConstraintFunction::IsSubclass => { ClassInfoConstraintFunction::IsSubclass => {
SubclassOfType::from(db, class.default_specialization(db)) SubclassOfType::from(db, class.top_materialization(db))
} }
}; };