Compare commits

..

4 Commits

Author SHA1 Message Date
Charlie Marsh
70859e4ba7 Add fast path for object vs unbounded inferable TypeVar
This handles the common case where we're checking if `object` (the
implicit positive element of a pure negation like `~str`) is assignable
to a generic type parameter like `_T` in `Iterator[_T]`.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:57:49 -05:00
Charlie Marsh
0eb28df0b0 Add more fast-paths 2026-01-13 20:36:53 -05:00
Charlie Marsh
3dba88b2fe Add more fast-paths 2026-01-13 20:19:29 -05:00
Charlie Marsh
6de4b1daf7 Add neg 2026-01-13 20:07:10 -05:00
10 changed files with 112 additions and 324 deletions

View File

@@ -797,7 +797,7 @@ class B(A):
pass
class C[T]:
def check(self, x: object) -> TypeIs[T]:
def check(x: object) -> TypeIs[T]:
# this is a bad check, but we only care about it type-checking
return False
@@ -835,7 +835,7 @@ class B(A):
pass
class C[T]:
def check(self, x: object) -> TypeGuard[T]:
def check(x: object) -> TypeGuard[T]:
# this is a bad check, but we only care about it type-checking
return False

View File

@@ -951,3 +951,17 @@ for x in Bar:
# TODO: should reveal `Any`
reveal_type(x) # revealed: Unknown
```
## Iterating over a list with a negated type parameter
When we have a list with a negated type parameter (e.g., `list[~str]`), we should still be able to
iterate over it correctly. The negated type parameter represents all types except `str`, and
`list[~str]` is still a valid list that can be iterated.
```py
from ty_extensions import Not
def _(value: list[Not[str]]):
for x in value:
reveal_type(x) # revealed: ~str
```

View File

@@ -14,8 +14,8 @@ def _(
b: TypeIs[str | int],
c: TypeGuard[bool],
d: TypeIs[tuple[TypeOf[bytes]]],
e: TypeGuard, # error: [invalid-type-form] "`typing.TypeGuard` requires exactly one argument when used in a type expression"
f: TypeIs, # error: [invalid-type-form] "`typing.TypeIs` requires exactly one argument when used in a type expression"
e: TypeGuard, # error: [invalid-type-form]
f: TypeIs, # error: [invalid-type-form]
):
reveal_type(a) # revealed: TypeGuard[str]
reveal_type(b) # revealed: TypeIs[str | int]
@@ -46,23 +46,12 @@ A user-defined type guard must accept at least one positional argument (in addit
for non-static methods).
```pyi
from typing import Any, TypeVar
from typing_extensions import TypeGuard, TypeIs
T = TypeVar("T")
# Multiple parameters are allowed
def is_str_list(val: list[object], allow_empty: bool) -> TypeGuard[list[str]]: ...
def is_set_of(val: set[Any], type: type[T]) -> TypeGuard[set[T]]: ...
def is_two_element_tuple(val: tuple[object, ...], a: str, b: str) -> TypeIs[tuple[str, str]]: ...
# error: [invalid-type-guard-definition] "`TypeGuard` function must have a parameter to narrow"
# TODO: error: [invalid-type-guard-definition]
def _() -> TypeGuard[str]: ...
# error: [invalid-type-guard-definition] "`TypeGuard` function must have a parameter to narrow"
def _(*args) -> TypeGuard[str]: ...
# error: [invalid-type-guard-definition] "`TypeIs` function must have a parameter to narrow"
# TODO: error: [invalid-type-guard-definition]
def _(**kwargs) -> TypeIs[str]: ...
class _:
@@ -74,14 +63,14 @@ class _:
def _(a) -> TypeIs[str]: ...
# errors
def _(self) -> TypeGuard[str]: ... # error: [invalid-type-guard-definition] "`TypeGuard` function must have a parameter to narrow"
def _(self, /, *, a) -> TypeGuard[str]: ... # error: [invalid-type-guard-definition] "`TypeGuard` function must have a parameter to narrow"
def _(self) -> TypeGuard[str]: ... # TODO: error: [invalid-type-guard-definition]
def _(self, /, *, a) -> TypeGuard[str]: ... # TODO: error: [invalid-type-guard-definition]
@classmethod
def _(cls) -> TypeIs[str]: ... # error: [invalid-type-guard-definition] "`TypeIs` function must have a parameter to narrow"
def _(cls) -> TypeIs[str]: ... # TODO: error: [invalid-type-guard-definition]
@classmethod
def _() -> TypeIs[str]: ... # error: [invalid-type-guard-definition] "`TypeIs` function must have a parameter to narrow"
def _() -> TypeIs[str]: ... # TODO: error: [invalid-type-guard-definition]
@staticmethod
def _(*, a) -> TypeGuard[str]: ... # error: [invalid-type-guard-definition] "`TypeGuard` function must have a parameter to narrow"
def _(*, a) -> TypeGuard[str]: ... # TODO: error: [invalid-type-guard-definition]
```
For `TypeIs` functions, the narrowed type must be assignable to the declared type of that parameter,
@@ -97,10 +86,10 @@ def _(a: tuple[object]) -> TypeIs[tuple[str]]: ...
def _(a: str | Any) -> TypeIs[str]: ...
def _(a) -> TypeIs[str]: ...
# error: [invalid-type-guard-definition] "Narrowed type `str` is not assignable to the declared parameter type `int`"
# TODO: error: [invalid-type-guard-definition]
def _(a: int) -> TypeIs[str]: ...
# error: [invalid-type-guard-definition] "Narrowed type `int` is not assignable to the declared parameter type `bool | str`"
# TODO: error: [invalid-type-guard-definition]
def _(a: bool | str) -> TypeIs[int]: ...
```
@@ -118,14 +107,12 @@ class C:
@classmethod
def g(cls, x: object) -> TypeGuard[int]:
return True
def h(
self,
) -> TypeGuard[str]: # error: [invalid-type-guard-definition] "`TypeGuard` function must have a parameter to narrow"
# TODO: this could error at definition time
def h(self) -> TypeGuard[str]:
return True
# TODO: this could error at definition time
@classmethod
def j(cls) -> TypeGuard[int]: # error: [invalid-type-guard-definition] "`TypeGuard` function must have a parameter to narrow"
def j(cls) -> TypeGuard[int]:
return True
def _(x: object):
@@ -234,7 +221,7 @@ def g(a: object) -> TypeIs[int]:
return True
def _(d: Any):
if f(): # error: [missing-argument] "No argument provided for required parameter `a` of function `f`"
if f(): # error: [missing-argument]
...
if g(*d):
@@ -243,7 +230,7 @@ def _(d: Any):
if f("foo"): # TODO: error: [invalid-type-guard-call]
...
if g(a=d): # error: [invalid-type-guard-call] "Type guard call does not have a target"
if g(a=d): # error: [invalid-type-guard-call]
...
```

View File

@@ -1,43 +0,0 @@
# Subscripts involving type aliases
Aliases are expanded during analysis of subscripts.
```toml
[environment]
python-version = "3.12"
```
```py
from typing_extensions import TypeAlias, Literal
ImplicitTuple = tuple[str, int, int]
PEP613Tuple: TypeAlias = tuple[str, int, int]
type PEP695Tuple = tuple[str, int, int]
ImplicitZero = Literal[0]
PEP613Zero: TypeAlias = Literal[0]
type PEP695Zero = Literal[0]
def f(
implicit_tuple: ImplicitTuple,
pep_613_tuple: PEP613Tuple,
pep_695_tuple: PEP695Tuple,
implicit_zero: ImplicitZero,
pep_613_zero: PEP613Zero,
pep_695_zero: PEP695Zero,
):
reveal_type(implicit_tuple[:2]) # revealed: tuple[str, int]
reveal_type(implicit_tuple[implicit_zero]) # revealed: str
reveal_type(implicit_tuple[pep_613_zero]) # revealed: str
reveal_type(implicit_tuple[pep_695_zero]) # revealed: str
reveal_type(pep_613_tuple[:2]) # revealed: tuple[str, int]
reveal_type(pep_613_tuple[implicit_zero]) # revealed: str
reveal_type(pep_613_tuple[pep_613_zero]) # revealed: str
reveal_type(pep_613_tuple[pep_695_zero]) # revealed: str
reveal_type(pep_695_tuple[:2]) # revealed: tuple[str, int]
reveal_type(pep_695_tuple[implicit_zero]) # revealed: str
reveal_type(pep_695_tuple[pep_613_zero]) # revealed: str
reveal_type(pep_695_tuple[pep_695_zero]) # revealed: str
```

View File

@@ -106,5 +106,5 @@ class Bar:
def f(x: Foo):
if isinstance(x, Bar):
# TODO: should be `int`
reveal_type(x["whatever"]) # revealed: @Todo(Subscript expressions with intersections)
reveal_type(x["whatever"]) # revealed: @Todo(Subscript expressions on intersections)
```

View File

@@ -80,17 +80,6 @@ def _(m: int, n: int, s2: str):
reveal_type(substring2) # revealed: str
```
## LiteralString
```py
from typing_extensions import LiteralString
def f(x: LiteralString):
reveal_type(x[0]) # revealed: LiteralString
reveal_type(x[True]) # revealed: LiteralString
reveal_type(x[1:42]) # revealed: LiteralString
```
## Unsupported slice types
```py

View File

@@ -430,5 +430,5 @@ class Bar: ...
def test4(val: Intersection[tuple[Foo], tuple[Bar]]):
# TODO: should be `Foo & Bar`
reveal_type(val[0]) # revealed: @Todo(Subscript expressions with intersections)
reveal_type(val[0]) # revealed: @Todo(Subscript expressions on intersections)
```

View File

@@ -1,89 +0,0 @@
# Subscripts involving type variables
## TypeVar bound/constrained to a tuple/int-literal/bool-literal
The upper bounds of type variables are considered when analysing subscripts.
```toml
[environment]
python-version = "3.12"
```
```py
from typing_extensions import TypeAlias, Literal
ImplicitTuple = tuple[str, int, int]
PEP613Tuple: TypeAlias = tuple[str, int, int]
type PEP695Tuple = tuple[str, int, int]
ImplicitZero = Literal[0]
PEP613Zero: TypeAlias = Literal[0]
type PEP695Zero = Literal[0]
# fmt: off
def f[
BoundedTupleT: tuple[str, int, bytes],
ConstrainedTupleT: (tuple[str, int, bytes], tuple[int, bytes, str]),
BoundedZeroT: Literal[0],
ConstrainedIntLiteralT: (Literal[0], Literal[1])
](
tuple_1: BoundedTupleT,
tuple_2: ConstrainedTupleT,
zero: BoundedZeroT,
some_integer: ConstrainedIntLiteralT,
):
# TODO: would ideally be `tuple[str, int]`
reveal_type(tuple_1[:2]) # revealed: tuple[str | int | bytes, ...]
reveal_type(tuple_1[zero]) # revealed: str
# TODO: ideally this might be `str | int`,
# but it's hard to do that without introducing false positives elsewhere
reveal_type(tuple_1[some_integer]) # revealed: str | int | bytes
# TODO: would ideally be `tuple[str, int] | tuple[int, bytes]`
reveal_type(tuple_2[:2]) # revealed: tuple[str | int | bytes, ...]
reveal_type(tuple_2[zero]) # revealed: str | int
reveal_type(tuple_2[some_integer]) # revealed: str | int | bytes
# fmt: on
```
## TypeVars
```toml
[environment]
python-version = "3.12"
```
```py
from typing import Protocol
class SupportsLessThan(Protocol):
def __lt__(self, other, /) -> bool: ...
def f[K: SupportsLessThan](dictionary: dict[K, int], key: K):
reveal_type(dictionary[key]) # revealed: int
```
## ParamSpecs
```toml
[environment]
python-version = "3.12"
```
```py
from typing import Callable
def decorator[**P, T](func: Callable[P, T]) -> Callable[P, T]:
def inner(*args: P.args, **kwargs: P.kwargs) -> T:
if len(args) > 0:
# error: [invalid-assignment]
args = args[1:]
# `func` requires the full `ParamSpec` passed into `decorator`,
# but here the first argument is skipped, so we should possibly emit an error here:
return func(*args, **kwargs)
return inner
```

View File

@@ -68,8 +68,8 @@ use crate::types::diagnostic::{
INVALID_GENERIC_ENUM, INVALID_KEY, INVALID_LEGACY_TYPE_VARIABLE, INVALID_METACLASS,
INVALID_NAMED_TUPLE, INVALID_NEWTYPE, INVALID_OVERLOAD, INVALID_PARAMETER_DEFAULT,
INVALID_PARAMSPEC, INVALID_PROTOCOL, INVALID_TYPE_ARGUMENTS, INVALID_TYPE_FORM,
INVALID_TYPE_GUARD_CALL, INVALID_TYPE_GUARD_DEFINITION, INVALID_TYPE_VARIABLE_CONSTRAINTS,
INVALID_TYPED_DICT_STATEMENT, IncompatibleBases, NOT_SUBSCRIPTABLE, POSSIBLY_MISSING_ATTRIBUTE,
INVALID_TYPE_GUARD_CALL, INVALID_TYPE_VARIABLE_CONSTRAINTS, INVALID_TYPED_DICT_STATEMENT,
IncompatibleBases, NOT_SUBSCRIPTABLE, POSSIBLY_MISSING_ATTRIBUTE,
POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_FINAL_CLASS,
TypedDictDeleteErrorKind, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL,
UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_DYNAMIC_BASE, UNSUPPORTED_OPERATOR,
@@ -587,7 +587,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
if self.db().should_check_file(self.file()) {
self.check_static_class_definitions();
self.check_overloaded_functions(node);
self.check_type_guard_definitions();
}
}
@@ -1453,85 +1452,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
}
/// Check that all type guard function definitions have at least one positional parameter
/// (in addition to `self`/`cls` for methods), and for `TypeIs`, that the narrowed type is
/// assignable to the declared type of that parameter.
fn check_type_guard_definitions(&mut self) {
for (definition, ty) in self.declarations.iter() {
// Only check actual function definitions, not imports.
let DefinitionKind::Function(function_ref) = definition.kind(self.db()) else {
continue;
};
let Some(function) = ty.inner_type().as_function_literal() else {
continue;
};
for overload in function.iter_overloads_and_implementation(self.db()) {
let signature = overload.signature(self.db());
let return_ty = signature.return_ty;
// Check if this is a `TypeIs` or `TypeGuard` return type.
let (type_guard_form_name, narrowed_type) = match return_ty {
Type::TypeIs(type_is) => ("TypeIs", Some(type_is.return_type(self.db()))),
Type::TypeGuard(_) => ("TypeGuard", None),
_ => continue,
};
let function_node = function_ref.node(self.module());
// The return type annotation must exist since we matched `TypeIs`/`TypeGuard`.
let Some(returns_expr) = function_node.returns.as_deref() else {
continue;
};
// Check if this is a non-static method (first parameter is implicit `self`/`cls`).
let is_method = self
.index
.class_definition_of_method(
overload.body_scope(self.db()).file_scope_id(self.db()),
)
.is_some();
let has_implicit_receiver = is_method && !overload.is_staticmethod(self.db());
// Find the first positional parameter to narrow (skip implicit `self`/`cls`).
let positional_params: Vec<_> = signature.parameters().positional().collect();
let first_narrowed_param_index = usize::from(has_implicit_receiver);
let first_narrowed_param = positional_params.get(first_narrowed_param_index);
let Some(first_narrowed_param) = first_narrowed_param else {
if let Some(builder) = self
.context
.report_lint(&INVALID_TYPE_GUARD_DEFINITION, returns_expr)
{
builder.into_diagnostic(format_args!(
"`{type_guard_form_name}` function must have a parameter to narrow"
));
}
continue;
};
// For `TypeIs`, check that the narrowed type is assignable to the parameter type.
if let Some(narrowed_ty) = narrowed_type {
let param_ty = first_narrowed_param.annotated_type();
if !narrowed_ty.is_assignable_to(self.db(), param_ty) {
if let Some(builder) = self
.context
.report_lint(&INVALID_TYPE_GUARD_DEFINITION, returns_expr)
{
builder.into_diagnostic(format_args!(
"Narrowed type `{narrowed}` is not assignable \
to the declared parameter type `{param}`",
narrowed = narrowed_ty.display(self.db()),
param = param_ty.display(self.db())
));
}
}
}
}
}
}
fn infer_region_definition(&mut self, definition: Definition<'db>) {
match definition.kind(self.db()) {
DefinitionKind::Function(function) => {
@@ -13321,36 +13241,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let value_node = subscript.value.as_ref();
let inferred = match (value_ty, slice_ty) {
(Type::Dynamic(_) | Type::Never, _) => Some(value_ty),
(Type::TypeAlias(alias), _) => Some(self.infer_subscript_expression_types(
subscript,
alias.value_type(self.db()),
slice_ty,
expr_context,
)),
(_, Type::TypeAlias(alias)) => Some(self.infer_subscript_expression_types(
subscript,
value_ty,
alias.value_type(self.db()),
expr_context,
)),
(Type::Union(union), _) => Some(union.map(db, |element| {
self.infer_subscript_expression_types(subscript, *element, slice_ty, expr_context)
})),
(_, Type::Union(union)) => Some(union.map(db, |element| {
self.infer_subscript_expression_types(subscript, value_ty, *element, expr_context)
})),
// TODO: we can map over the intersection and fold the results back into an intersection,
// but we need to make sure we avoid emitting a diagnostic if one positive element has a `__getitem__`
// method but another does not. This means `infer_subscript_expression_types`
// needs to return a `Result` rather than eagerly emitting diagnostics.
(Type::Intersection(_), _) | (_, Type::Intersection(_)) => {
Some(todo_type!("Subscript expressions with intersections"))
(Type::Intersection(_), _) => {
Some(todo_type!("Subscript expressions on intersections"))
}
// Ex) Given `("a", "b", "c", "d")[1]`, return `"b"`
@@ -13430,16 +13330,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
}),
(Type::LiteralString, Type::IntLiteral(_) | Type::BooleanLiteral(_)) => {
Some(Type::LiteralString)
}
(Type::LiteralString, Type::NominalInstance(nominal))
if nominal.slice_literal(db).is_some() =>
{
Some(Type::LiteralString)
}
// Ex) Given `b"value"[1]`, return `97` (i.e., `ord(b"a")`)
(Type::BytesLiteral(literal_ty), Type::IntLiteral(i64_int)) => {
i32::try_from(i64_int).ok().map(|i32_int| {
@@ -13571,39 +13461,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
Some(todo_type!("Inference of subscript on special form"))
}
(
Type::FunctionLiteral(_)
| Type::WrapperDescriptor(_)
| Type::BoundMethod(_)
| Type::DataclassDecorator(_)
| Type::DataclassTransformer(_)
| Type::Callable(_)
| Type::ModuleLiteral(_)
| Type::ClassLiteral(_)
| Type::GenericAlias(_)
| Type::SubclassOf(_)
| Type::AlwaysFalsy
| Type::AlwaysTruthy
| Type::IntLiteral(_)
| Type::BooleanLiteral(_)
| Type::ProtocolInstance(_)
| Type::PropertyInstance(_)
| Type::EnumLiteral(_)
| Type::BoundSuper(_)
| Type::TypeIs(_)
| Type::TypeGuard(_)
| Type::TypedDict(_)
| Type::NewTypeInstance(_)
| Type::NominalInstance(_)
| Type::SpecialForm(_)
| Type::KnownInstance(_)
| Type::StringLiteral(_)
| Type::BytesLiteral(_)
| Type::LiteralString
| Type::TypeVar(_) // TODO: more complex logic required here!
| Type::KnownBoundMethod(_),
_,
) => None,
_ => None,
};
if let Some(inferred) = inferred {

View File

@@ -651,6 +651,48 @@ impl<'db> Type<'db> {
ConstraintSet::from(true)
}
// Fast path: `object` is not a subtype of any nominal instance type other than itself.
// This is important for performance when checking intersections with no positive
// elements (pure negations like `~str`), which are treated as having `object` as
// the implicit positive element.
(Type::NominalInstance(source), Type::NominalInstance(_)) if source.is_object() => {
ConstraintSet::from(false)
}
// Fast path: `object` (an instance type) is not a subtype of any `type[X]` (a class type).
(Type::NominalInstance(source), Type::SubclassOf(_)) if source.is_object() => {
ConstraintSet::from(false)
}
// Fast path: `object` is not a subtype of any non-inferable type variable, since the
// type variable could be specialized to a type smaller than `object`.
(Type::NominalInstance(source), Type::TypeVar(typevar))
if source.is_object() && !typevar.is_inferable(db, inferable) =>
{
ConstraintSet::from(false)
}
// Fast path: `object` is assignable to any inferable type variable with no upper bound
// (or with `object` as its upper bound), which is the common case for generic
// type parameters like `_T` in `Iterator[_T]`.
(Type::NominalInstance(source), Type::TypeVar(typevar))
if source.is_object()
&& typevar.is_inferable(db, inferable)
&& relation.is_assignability()
&& typevar
.typevar(db)
.upper_bound(db)
.is_none_or(|bound| bound.is_object()) =>
{
ConstraintSet::from(true)
}
// Fast path: `object` is not a subtype of any callable type, since not all objects
// are callable.
(Type::NominalInstance(source), Type::Callable(_)) if source.is_object() => {
ConstraintSet::from(false)
}
// `Never` is the bottom type, the empty set.
(_, Type::Never) => ConstraintSet::from(false),
@@ -796,7 +838,37 @@ impl<'db> Type<'db> {
})
}),
// Fast path for pure negations (~X): these are semantically `object & ~X`, so they're
// only assignable to types that `object` is assignable to. Since `object` is only
// assignable to `object`, dynamic types, unions/protocols/intersections that might
// contain `object`, we can short-circuit most cases directly.
(Type::Intersection(intersection), _) if intersection.positive(db).is_empty() => {
match target {
// `object` is a subtype of `object`
_ if target.is_object() => ConstraintSet::from(true),
// `object` is a subtype of dynamic types
Type::Dynamic(_) => ConstraintSet::from(true),
// These cases need more complex checking - delegate to full machinery
// (TypeVar needs special handling for inference)
Type::Union(_)
| Type::ProtocolInstance(_)
| Type::Intersection(_)
| Type::TypeVar(_) => Type::object().has_relation_to_impl(
db,
target,
inferable,
relation,
relation_visitor,
disjointness_visitor,
),
// `object` is not a subtype of any other type
_ => ConstraintSet::from(false),
}
}
(Type::Intersection(intersection), _) => {
// An intersection type is a subtype of another type if at least one of its
// positive elements is a subtype of that type.
intersection.positive(db).iter().when_any(db, |&elem_ty| {
elem_ty.has_relation_to_impl(
db,