[ty] Fix constructor calls on narrowed type-variable intersections (#27493)

## Summary

In #27449, we start treating calls to `type[object]` strictly (using
`object.__init__` rather than the overly forgiving `type.__call__`).
This also means that `type[T] & type[SomeType]` intersections (which can
easily arise with a parameter `cls: type[T]` which is then narrowed via
`issubclass(cls, SomeType)`) start validating the `type[T]` portion
against `object.__init__` (assuming `T` has no upper bound besides
`object`). That means if the call to `type[SomeType]` fails, we now get
confusing double diagnostics also complaining about the failed call to
`object.__init__`.

This PR resolves that problem separately in a general way: if we have an
intersection `type[T] & type[SomeClass]`, where `SomeClass` is a
subclass of the upper bound of `T`, we don't try calling both
constructors; we just try the constructor of `SomeClass`. (But we
preserve the full intersection as receiver, so that the result of the
call will still be `T & SomeClass`, not just `SomeClass`.) Since all
classes are subclasses of `object`, this means we don't try calling
`object.__init__` or emit errors about it when they are redundant and
confusing in an intersection with some more specific `type[...]`.

(This is only relevant to intersections with `type[T]` where `T` is a
typevar, since a normal `type[Base] & type[Child]` intersection would
immediately simplify to `type[Child]`. That simplification doesn't occur
with a typevar, since we need to preserve the typevar identity; it may
actually represent a narrower type, not its upper bound.)

- Resolve constructor calls on narrowed `type[T] & type[Child]`
intersections using the applicable subclass constructor instead of
treating the type-variable bound as an independent alternative.
- Preserve the precise `T & Child` result, report only
subclass-constructor argument errors, and retain specialized generic
constructors, built-in behavior, independent metaclass callables, and
explicit `__new__` / metaclass `__call__` return types.

## Test plan

- Added constructor mdtests for `issubclass`-narrowed bounded,
constrained, and unbounded type variables; valid return types; rejected
arguments; and deduplicated diagnostics.
- Covered explicitly specialized generic constructors,
literal-preserving built-in constructors, `Self` returns, non-instance
`__new__` and metaclass `__call__` returns, and intersections with
independent metaclass callables.

Ecosystem changes are both correct/improvements.
This commit is contained in:
Carl Meyer authored and GitHub committed 2026-08-04 19:12:59 -07:00
1 parent ea933ff891
commit 2ddadb9937
5 files changed
+238 -11

No files matched your search

@@ -1512,6 +1512,193 @@ def f(cls: type[T]):
reveal_type(cls(1, "foo")) # revealed: T@f
```
## Intersection constructors
```toml
[environment]
python-version = "3.12"
```
### Narrowed bound type variables
Narrowing a bounded class type with `issubclass` must use the subclass constructor while preserving
both the original type variable and the narrowed subclass in the return type.
```py
class Base:
def __init__(self, value: str) -> None: ...
class IntConstructor(Base):
def __init__(self, value: int) -> None: ...
def valid[T: Base](cls: type[T]) -> T:
if issubclass(cls, IntConstructor):
reveal_type(cls) # revealed: type[T@valid] & type[IntConstructor]
reveal_type(cls(1)) # revealed: T@valid & IntConstructor
return cls(1)
return cls("ok")
```
An argument accepted by the bound's constructor must still be rejected by the narrowed subclass.
Arguments rejected by both constructors produce only the subclass constructor's diagnostic.
```py
def invalid_arguments[T: Base](cls: type[T]) -> None:
if issubclass(cls, IntConstructor):
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
cls("wrong")
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `None`"
cls(None)
```
### Narrowed constrained type variables
Narrowing a constrained type variable selects the matching constructor without losing the original
type variable in the return type.
```py
class StringConstructor:
def __init__(self, value: str) -> None: ...
class IntConstructor:
def __init__(self, value: int) -> None: ...
def construct[T: (StringConstructor, IntConstructor)](cls: type[T]) -> None:
if issubclass(cls, IntConstructor):
reveal_type(cls) # revealed: type[T@construct] & type[IntConstructor]
reveal_type(cls(1)) # revealed: T@construct & IntConstructor
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
cls("wrong")
```
### Narrowed unbounded type variables
The narrowed subclass constructor also determines which arguments are valid when the original type
variable has no upper bound.
```py
class IntConstructor:
def __init__(self, value: int) -> None: ...
def construct[T](cls: type[T]) -> None:
if issubclass(cls, IntConstructor):
reveal_type(cls) # revealed: type[T@construct] & type[IntConstructor]
reveal_type(cls(1)) # revealed: T@construct & IntConstructor
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
cls("wrong")
```
### Specialized generic constructors
A generic constructor provider must retain its explicit specialization when validating arguments and
preserve the original type variable in its return type.
```py
from ty_extensions import Intersection
class Box[S]:
def __init__(self, value: S) -> None: ...
def construct[T](cls: Intersection[type[T], type[Box[int]]]) -> None:
reveal_type(cls(1)) # revealed: T@construct & Box[int]
# error: [invalid-argument-type] "Argument to `Box.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
cls("wrong")
```
### Built-in constructor behavior
Narrowing to a final built-in class must retain its specialized constructor behavior and the
original type variable.
```py
def construct[T](cls: type[T]) -> None:
if issubclass(cls, bool):
reveal_type(cls) # revealed: type[T@construct] & <class 'bool'>
reveal_type(cls(1)) # revealed: T@construct & Literal[True]
```
### `Self` constructor returns
An explicit `Self` return still represents the constructed instance, so narrowing must preserve the
original type variable and validate the subclass initializer.
```py
from typing import Self
class Base:
def __init__(self, value: str) -> None: ...
class NewChild(Base):
def __new__(cls, value: object) -> Self:
return object.__new__(cls)
def __init__(self, value: int) -> None: ...
def construct[T: Base](cls: type[T]) -> None:
if issubclass(cls, NewChild):
reveal_type(cls(1)) # revealed: T@construct & NewChild
# error: [invalid-argument-type] "Argument to `NewChild.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
cls("wrong")
```
### Non-instance `__new__` returns
A constructor explicitly returning a non-instance type must retain that return type instead of
intersecting it with the original type variable.
```py
class Base:
def __init__(self, value: str) -> None: ...
class ReturnsString(Base):
def __new__(cls, value: int) -> str:
return str(value)
def construct[T: Base](cls: type[T]) -> None:
if issubclass(cls, ReturnsString):
reveal_type(cls(1)) # revealed: str
```
### Non-instance metaclass `__call__` returns
A custom metaclass's explicit non-instance return similarly takes precedence over the original type
variable.
```py
class StringFactory(type):
def __call__(cls, value: int) -> str:
return str(value)
class Base:
def __init__(self, value: str) -> None: ...
class Factory(Base, metaclass=StringFactory): ...
def construct[T: Base](cls: type[T]) -> None:
if issubclass(cls, Factory):
reveal_type(cls(1)) # revealed: str
```
### Independent metaclass callables
An intersection of a class-object type and a metaclass instance retains both independent callables.
Arguments accepted by only one callable use that callable's return type.
```py
from ty_extensions import Intersection
class StringBase:
def __init__(self, value: str) -> None: ...
class IntMeta(type):
def __call__(cls, value: int) -> str:
return str(value)
def construct(cls: Intersection[type[StringBase], IntMeta]) -> None:
reveal_type(cls(1)) # revealed: str
reveal_type(cls("ok")) # revealed: StringBase
```
## Union of constructors
```py
@@ -223,22 +223,16 @@ def narrowed_subclass[T](cls: type[T]) -> T:
return cls()
```
An invalid call after narrowing should report only the narrowed constructor's argument error.
Existing intersection-call issues currently add a redundant error from the original upper bound:
Invalid positional and keyword arguments each produce only the narrowed subclass constructor's
diagnostic:
```py
def narrowed_invalid[T](cls: type[T]) -> None:
if issubclass(cls, IntConstructor):
# TODO: Only report `invalid-argument-type`; the upper-bound constructor also reports
# `too-many-positional-arguments` and describes the attempted intersection as `Never`.
# error: [too-many-positional-arguments]
# error: [invalid-argument-type]
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
cls("wrong")
# TODO: Only report `invalid-argument-type`; the upper-bound constructor also reports
# `unknown-argument` for the same call.
# error: [unknown-argument]
# error: [invalid-argument-type]
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
cls(value="wrong")
```
+26
View File
@@ -5254,6 +5254,32 @@ impl<'db> Type<'db> {
.map(|element| element.bindings(db, env)),
),
// A narrowed `type[T: Base] & type[Child]` still needs to construct `T & Child`,
// but its constructor must come from `Child`, not from `Base` as an independent,
// competing alternative. Flattening the projected instance lets intersection
// simplification select that constructor without discarding unrelated providers.
Type::Intersection(intersection)
if intersection.positive(db).iter().all(|element| {
// A metaclass instance also has an instance-space projection, but it can
// provide an independent `__call__`. Only simplify actual class-object
// variants so `type[Base] & Meta` retains both callable candidates.
matches!(
element.resolve_type_alias(db),
Type::ClassLiteral(_) | Type::GenericAlias(_) | Type::SubclassOf(_)
)
}) && let Some(instance_type) = self.to_instance_approximation(db, env)
&& let Type::NominalInstance(lookup_instance) =
instance_type.flatten_typevars(db, env)
&& let Some(bindings) = {
let bindings = lookup_instance.to_meta_type(db, env).bindings(db, env);
bindings.has_only_constructor_items().then_some(bindings)
} =>
{
bindings
.with_constructed_instance_type(db, instance_type)
.with_callable_type(self)
}
Type::Intersection(intersection) => Bindings::from_intersection(
self,
intersection
@@ -811,6 +811,15 @@ impl<'db> Bindings<'db> {
}
}
/// Set the overall receiver without replacing individual constructor callables.
pub(crate) fn with_callable_type(mut self, callable_type: Type<'db>) -> Self {
self.callable_type = callable_type;
for element in &mut self.elements {
element.callable_type = callable_type;
}
self
}
pub(crate) fn with_constructed_instance_type(
mut self,
db: &'db dyn Db,
@@ -916,6 +925,12 @@ impl<'db> Bindings<'db> {
.filter_map(CallableItem::as_constructor)
}
/// Return whether every callable uses ordinary constructor binding semantics.
pub(crate) fn has_only_constructor_items(&self) -> bool {
self.iter_callable_items()
.all(|item| item.as_constructor().is_some())
}
fn iter_constructor_items_mut(&mut self) -> impl Iterator<Item = &mut ConstructorBinding<'db>> {
self.iter_callable_items_mut()
.filter_map(CallableItem::as_constructor_mut)
@@ -641,7 +641,12 @@ impl<'db> ConstructorBinding<'db> {
db: &'db dyn Db,
env: &ProgramEnvironment<'db>,
) -> Option<ClassLiteral<'db>> {
self.constructed_instance_type()
let instance_type = self.constructed_instance_type();
let lookup_instance = match instance_type {
Type::Intersection(_) => instance_type.flatten_typevars(db, env),
_ => instance_type,
};
lookup_instance
.as_nominal_instance()
// TODO may need to handle `Type::KnownInstance` here as well?
.map(|instance| instance.class(db, env).class_literal(db))