mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-04 02:38:25 +00:00
[ty] Add partial support for TypeIs
(#18589)
## Summary Part of [#117](https://github.com/astral-sh/ty/issues/117). `TypeIs[]` is a special form that allows users to define their own narrowing functions. Despite the syntax, `TypeIs` is not a generic and, on its own, it is meaningless as a type. [Officially](https://typing.python.org/en/latest/spec/narrowing.html#typeis), a function annotated as returning a `TypeIs[T]` is a <i>type narrowing function</i>, where `T` is called the <i>`TypeIs` return type</i>. A `TypeIs[T]` may or may not be bound to a symbol. Only bound types have narrowing effect: ```python def f(v: object = object()) -> TypeIs[int]: ... a: str = returns_str() if reveal_type(f()): # Unbound: TypeIs[int] reveal_type(a) # str if reveal_type(f(a)): # Bound: TypeIs[a, int] reveal_type(a) # str & int ``` Delayed usages of a bound type has no effect, however: ```python b = f(a) if b: reveal_type(a) # str ``` A `TypeIs[T]` type: * Is fully static when `T` is fully static. * Is a singleton/single-valued when it is bound. * Has exactly two runtime inhabitants when it is unbound: `True` and `False`. In other words, an unbound type have ambiguous truthiness. It is possible to infer more precise truthiness for bound types; however, that is not part of this change. `TypeIs[T]` is a subtype of or otherwise assignable to `bool`. `TypeIs` is invariant with respect to the `TypeIs` return type: `TypeIs[int]` is neither a subtype nor a supertype of `TypeIs[bool]`. When ty sees a function marked as returning `TypeIs[T]`, its `return`s will be checked against `bool` instead. ty will also report such functions if they don't accept a positional argument. Addtionally, a type narrowing function call with no positional arguments (e.g., `f()` in the example above) will be considered invalid. ## Test Plan Markdown tests. --------- Co-authored-by: Carl Meyer <carl@astral.sh>
This commit is contained in:
parent
89d915a1e3
commit
6d56ee803e
15 changed files with 841 additions and 97 deletions
|
@ -19,7 +19,6 @@ def f(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]:
|
|||
reveal_type(Alias) # revealed: @Todo(Support for `typing.TypeAlias`)
|
||||
|
||||
def g() -> TypeGuard[int]: ...
|
||||
def h() -> TypeIs[int]: ...
|
||||
def i(callback: Callable[Concatenate[int, P], R_co], *args: P.args, **kwargs: P.kwargs) -> R_co:
|
||||
reveal_type(args) # revealed: tuple[@Todo(Support for `typing.ParamSpec`), ...]
|
||||
reveal_type(kwargs) # revealed: dict[str, @Todo(Support for `typing.ParamSpec`)]
|
||||
|
|
330
crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md
Normal file
330
crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md
Normal file
|
@ -0,0 +1,330 @@
|
|||
# User-defined type guards
|
||||
|
||||
User-defined type guards are functions of which the return type is either `TypeGuard[...]` or
|
||||
`TypeIs[...]`.
|
||||
|
||||
## Display
|
||||
|
||||
```py
|
||||
from ty_extensions import Intersection, Not, TypeOf
|
||||
from typing_extensions import TypeGuard, TypeIs
|
||||
|
||||
def _(
|
||||
a: TypeGuard[str],
|
||||
b: TypeIs[str | int],
|
||||
c: TypeGuard[Intersection[complex, Not[int], Not[float]]],
|
||||
d: TypeIs[tuple[TypeOf[bytes]]],
|
||||
e: TypeGuard, # error: [invalid-type-form]
|
||||
f: TypeIs, # error: [invalid-type-form]
|
||||
):
|
||||
# TODO: Should be `TypeGuard[str]`
|
||||
reveal_type(a) # revealed: @Todo(`TypeGuard[]` special form)
|
||||
reveal_type(b) # revealed: TypeIs[str | int]
|
||||
# TODO: Should be `TypeGuard[complex & ~int & ~float]`
|
||||
reveal_type(c) # revealed: @Todo(`TypeGuard[]` special form)
|
||||
reveal_type(d) # revealed: TypeIs[tuple[<class 'bytes'>]]
|
||||
reveal_type(e) # revealed: Unknown
|
||||
reveal_type(f) # revealed: Unknown
|
||||
|
||||
# TODO: error: [invalid-return-type] "Function always implicitly returns `None`, which is not assignable to return type `TypeGuard[str]`"
|
||||
def _(a) -> TypeGuard[str]: ...
|
||||
|
||||
# error: [invalid-return-type] "Function always implicitly returns `None`, which is not assignable to return type `TypeIs[str]`"
|
||||
def _(a) -> TypeIs[str]: ...
|
||||
def f(a) -> TypeGuard[str]:
|
||||
return True
|
||||
|
||||
def g(a) -> TypeIs[str]:
|
||||
return True
|
||||
|
||||
def _(a: object):
|
||||
# TODO: Should be `TypeGuard[str @ a]`
|
||||
reveal_type(f(a)) # revealed: @Todo(`TypeGuard[]` special form)
|
||||
reveal_type(g(a)) # revealed: TypeIs[str @ a]
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
A user-defined type guard must accept at least one positional argument (in addition to `self`/`cls`
|
||||
for non-static methods).
|
||||
|
||||
```pyi
|
||||
from typing_extensions import TypeGuard, TypeIs
|
||||
|
||||
# TODO: error: [invalid-type-guard-definition]
|
||||
def _() -> TypeGuard[str]: ...
|
||||
|
||||
# TODO: error: [invalid-type-guard-definition]
|
||||
def _(**kwargs) -> TypeIs[str]: ...
|
||||
|
||||
class _:
|
||||
# fine
|
||||
def _(self, /, a) -> TypeGuard[str]: ...
|
||||
@classmethod
|
||||
def _(cls, a) -> TypeGuard[str]: ...
|
||||
@staticmethod
|
||||
def _(a) -> TypeIs[str]: ...
|
||||
|
||||
# errors
|
||||
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]: ... # TODO: error: [invalid-type-guard-definition]
|
||||
@classmethod
|
||||
def _() -> TypeIs[str]: ... # TODO: error: [invalid-type-guard-definition]
|
||||
@staticmethod
|
||||
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,
|
||||
if any.
|
||||
|
||||
```pyi
|
||||
from typing import Any
|
||||
from typing_extensions import TypeIs
|
||||
|
||||
def _(a: object) -> TypeIs[str]: ...
|
||||
def _(a: Any) -> TypeIs[str]: ...
|
||||
def _(a: tuple[object]) -> TypeIs[tuple[str]]: ...
|
||||
def _(a: str | Any) -> TypeIs[str]: ...
|
||||
def _(a) -> TypeIs[str]: ...
|
||||
|
||||
# TODO: error: [invalid-type-guard-definition]
|
||||
def _(a: int) -> TypeIs[str]: ...
|
||||
|
||||
# TODO: error: [invalid-type-guard-definition]
|
||||
def _(a: bool | str) -> TypeIs[int]: ...
|
||||
```
|
||||
|
||||
## Arguments to special forms
|
||||
|
||||
`TypeGuard` and `TypeIs` accept exactly one type argument.
|
||||
|
||||
```py
|
||||
from typing_extensions import TypeGuard, TypeIs
|
||||
|
||||
a = 123
|
||||
|
||||
# TODO: error: [invalid-type-form]
|
||||
def f(_) -> TypeGuard[int, str]: ...
|
||||
|
||||
# error: [invalid-type-form] "Special form `typing.TypeIs` expected exactly one type parameter"
|
||||
# error: [invalid-type-form] "Variable of type `Literal[123]` is not allowed in a type expression"
|
||||
def g(_) -> TypeIs[a, str]: ...
|
||||
|
||||
# TODO: Should be `Unknown`
|
||||
reveal_type(f(0)) # revealed: @Todo(`TypeGuard[]` special form)
|
||||
reveal_type(g(0)) # revealed: Unknown
|
||||
```
|
||||
|
||||
## Return types
|
||||
|
||||
All code paths in a type guard function must return booleans.
|
||||
|
||||
```py
|
||||
from typing_extensions import Literal, TypeGuard, TypeIs, assert_never
|
||||
|
||||
def _(a: object, flag: bool) -> TypeGuard[str]:
|
||||
if flag:
|
||||
return 0
|
||||
|
||||
# TODO: error: [invalid-return-type] "Return type does not match returned value: expected `TypeIs[str]`, found `Literal["foo"]`"
|
||||
return "foo"
|
||||
|
||||
# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `TypeIs[str]`"
|
||||
def f(a: object, flag: bool) -> TypeIs[str]:
|
||||
if flag:
|
||||
# error: [invalid-return-type] "Return type does not match returned value: expected `TypeIs[str]`, found `float`"
|
||||
return 1.2
|
||||
|
||||
def g(a: Literal["foo", "bar"]) -> TypeIs[Literal["foo"]]:
|
||||
if a == "foo":
|
||||
# Logically wrong, but allowed regardless
|
||||
return False
|
||||
|
||||
return False
|
||||
```
|
||||
|
||||
## Invalid calls
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
from typing_extensions import TypeGuard, TypeIs
|
||||
|
||||
def f(a: object) -> TypeGuard[str]:
|
||||
return True
|
||||
|
||||
def g(a: object) -> TypeIs[int]:
|
||||
return True
|
||||
|
||||
def _(d: Any):
|
||||
if f(): # error: [missing-argument]
|
||||
...
|
||||
|
||||
# TODO: no error, once we support splatted call args
|
||||
if g(*d): # error: [missing-argument]
|
||||
...
|
||||
|
||||
if f("foo"): # TODO: error: [invalid-type-guard-call]
|
||||
...
|
||||
|
||||
if g(a=d): # error: [invalid-type-guard-call]
|
||||
...
|
||||
```
|
||||
|
||||
## Narrowing
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
from typing_extensions import TypeGuard, TypeIs
|
||||
|
||||
def guard_str(a: object) -> TypeGuard[str]:
|
||||
return True
|
||||
|
||||
def is_int(a: object) -> TypeIs[int]:
|
||||
return True
|
||||
```
|
||||
|
||||
```py
|
||||
def _(a: str | int):
|
||||
if guard_str(a):
|
||||
# TODO: Should be `str`
|
||||
reveal_type(a) # revealed: str | int
|
||||
else:
|
||||
reveal_type(a) # revealed: str | int
|
||||
|
||||
if is_int(a):
|
||||
reveal_type(a) # revealed: int
|
||||
else:
|
||||
reveal_type(a) # revealed: str & ~int
|
||||
```
|
||||
|
||||
Attribute and subscript narrowing is supported:
|
||||
|
||||
```py
|
||||
from typing_extensions import Any, Generic, Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
class C(Generic[T]):
|
||||
v: T
|
||||
|
||||
def _(a: tuple[str, int] | tuple[int, str], c: C[Any]):
|
||||
# TODO: Should be `TypeGuard[str @ a[1]]`
|
||||
if reveal_type(guard_str(a[1])): # revealed: @Todo(`TypeGuard[]` special form)
|
||||
# TODO: Should be `tuple[int, str]`
|
||||
reveal_type(a) # revealed: tuple[str, int] | tuple[int, str]
|
||||
# TODO: Should be `str`
|
||||
reveal_type(a[1]) # revealed: Unknown
|
||||
|
||||
if reveal_type(is_int(a[0])): # revealed: TypeIs[int @ a[0]]
|
||||
# TODO: Should be `tuple[int, str]`
|
||||
reveal_type(a) # revealed: tuple[str, int] | tuple[int, str]
|
||||
# TODO: Should be `int`
|
||||
reveal_type(a[0]) # revealed: Unknown
|
||||
|
||||
# TODO: Should be `TypeGuard[str @ c.v]`
|
||||
if reveal_type(guard_str(c.v)): # revealed: @Todo(`TypeGuard[]` special form)
|
||||
reveal_type(c) # revealed: C[Any]
|
||||
# TODO: Should be `str`
|
||||
reveal_type(c.v) # revealed: Any
|
||||
|
||||
if reveal_type(is_int(c.v)): # revealed: TypeIs[int @ c.v]
|
||||
reveal_type(c) # revealed: C[Any]
|
||||
# TODO: Should be `int`
|
||||
reveal_type(c.v) # revealed: Any
|
||||
```
|
||||
|
||||
Indirect usage is supported within the same scope:
|
||||
|
||||
```py
|
||||
def _(a: str | int):
|
||||
b = guard_str(a)
|
||||
c = is_int(a)
|
||||
|
||||
reveal_type(a) # revealed: str | int
|
||||
# TODO: Should be `TypeGuard[str @ a]`
|
||||
reveal_type(b) # revealed: @Todo(`TypeGuard[]` special form)
|
||||
reveal_type(c) # revealed: TypeIs[int @ a]
|
||||
|
||||
if b:
|
||||
# TODO should be `str`
|
||||
reveal_type(a) # revealed: str | int
|
||||
else:
|
||||
reveal_type(a) # revealed: str | int
|
||||
|
||||
if c:
|
||||
# TODO should be `int`
|
||||
reveal_type(a) # revealed: str | int
|
||||
else:
|
||||
# TODO should be `str & ~int`
|
||||
reveal_type(a) # revealed: str | int
|
||||
```
|
||||
|
||||
Further writes to the narrowed place invalidate the narrowing:
|
||||
|
||||
```py
|
||||
def _(x: str | int, flag: bool) -> None:
|
||||
b = is_int(x)
|
||||
reveal_type(b) # revealed: TypeIs[int @ x]
|
||||
|
||||
if flag:
|
||||
x = ""
|
||||
|
||||
if b:
|
||||
reveal_type(x) # revealed: str | int
|
||||
```
|
||||
|
||||
The `TypeIs` type remains effective across generic boundaries:
|
||||
|
||||
```py
|
||||
from typing_extensions import TypeVar, reveal_type
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
def f(v: object) -> TypeIs[int]:
|
||||
return True
|
||||
|
||||
def g(v: T) -> T:
|
||||
return v
|
||||
|
||||
def _(a: str):
|
||||
# `reveal_type()` has the type `[T]() -> T`
|
||||
if reveal_type(f(a)): # revealed: TypeIs[int @ a]
|
||||
reveal_type(a) # revealed: str & int
|
||||
|
||||
if g(f(a)):
|
||||
reveal_type(a) # revealed: str & int
|
||||
```
|
||||
|
||||
## `TypeGuard` special cases
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
from typing_extensions import TypeGuard, TypeIs
|
||||
|
||||
def guard_int(a: object) -> TypeGuard[int]:
|
||||
return True
|
||||
|
||||
def is_int(a: object) -> TypeIs[int]:
|
||||
return True
|
||||
|
||||
def does_not_narrow_in_negative_case(a: str | int):
|
||||
if not guard_int(a):
|
||||
# TODO: Should be `str`
|
||||
reveal_type(a) # revealed: str | int
|
||||
else:
|
||||
reveal_type(a) # revealed: str | int
|
||||
|
||||
def narrowed_type_must_be_exact(a: object, b: bool):
|
||||
if guard_int(b):
|
||||
# TODO: Should be `int`
|
||||
reveal_type(b) # revealed: bool
|
||||
|
||||
if isinstance(a, bool) and is_int(a):
|
||||
reveal_type(a) # revealed: bool
|
||||
|
||||
if isinstance(a, bool) and guard_int(a):
|
||||
# TODO: Should be `int`
|
||||
reveal_type(a) # revealed: bool
|
||||
```
|
|
@ -871,4 +871,20 @@ def g3(obj: Foo[tuple[A]]):
|
|||
f3(obj)
|
||||
```
|
||||
|
||||
## `TypeGuard` and `TypeIs`
|
||||
|
||||
`TypeGuard[...]` and `TypeIs[...]` are always assignable to `bool`.
|
||||
|
||||
```py
|
||||
from ty_extensions import Unknown, is_assignable_to, static_assert
|
||||
from typing_extensions import Any, TypeGuard, TypeIs
|
||||
|
||||
static_assert(is_assignable_to(TypeGuard[Unknown], bool))
|
||||
static_assert(is_assignable_to(TypeIs[Any], bool))
|
||||
|
||||
# TODO no error
|
||||
static_assert(not is_assignable_to(TypeGuard[Unknown], str)) # error: [static-assert-error]
|
||||
static_assert(not is_assignable_to(TypeIs[Any], str))
|
||||
```
|
||||
|
||||
[typing documentation]: https://typing.python.org/en/latest/spec/concepts.html#the-assignable-to-or-consistent-subtyping-relation
|
||||
|
|
|
@ -402,6 +402,20 @@ static_assert(is_disjoint_from(TypeOf[C.prop], D))
|
|||
static_assert(is_disjoint_from(D, TypeOf[C.prop]))
|
||||
```
|
||||
|
||||
### `TypeGuard` and `TypeIs`
|
||||
|
||||
```py
|
||||
from ty_extensions import static_assert, is_disjoint_from
|
||||
from typing_extensions import TypeGuard, TypeIs
|
||||
|
||||
static_assert(not is_disjoint_from(bool, TypeGuard[str]))
|
||||
static_assert(not is_disjoint_from(bool, TypeIs[str]))
|
||||
|
||||
# TODO no error
|
||||
static_assert(is_disjoint_from(str, TypeGuard[str])) # error: [static-assert-error]
|
||||
static_assert(is_disjoint_from(str, TypeIs[str]))
|
||||
```
|
||||
|
||||
## Callables
|
||||
|
||||
No two callable types are disjoint because there exists a non-empty callable type
|
||||
|
|
|
@ -342,6 +342,38 @@ static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal[""]]], Not[A
|
|||
static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal["", "a"]]], Not[AlwaysFalsy]))
|
||||
```
|
||||
|
||||
### `TypeGuard` and `TypeIs`
|
||||
|
||||
Fully-static `TypeGuard[...]` and `TypeIs[...]` are subtypes of `bool`.
|
||||
|
||||
```py
|
||||
from ty_extensions import is_subtype_of, static_assert
|
||||
from typing_extensions import TypeGuard, TypeIs
|
||||
|
||||
# TODO: TypeGuard
|
||||
# static_assert(is_subtype_of(TypeGuard[int], bool))
|
||||
# static_assert(is_subtype_of(TypeGuard[int], int))
|
||||
static_assert(is_subtype_of(TypeIs[str], bool))
|
||||
static_assert(is_subtype_of(TypeIs[str], int))
|
||||
```
|
||||
|
||||
`TypeIs` is invariant. `TypeGuard` is covariant.
|
||||
|
||||
```py
|
||||
from ty_extensions import is_equivalent_to, is_subtype_of, static_assert
|
||||
from typing_extensions import TypeGuard, TypeIs
|
||||
|
||||
# TODO: TypeGuard
|
||||
# static_assert(is_subtype_of(TypeGuard[int], TypeGuard[int]))
|
||||
# static_assert(is_subtype_of(TypeGuard[bool], TypeGuard[int]))
|
||||
static_assert(is_subtype_of(TypeIs[int], TypeIs[int]))
|
||||
static_assert(is_subtype_of(TypeIs[int], TypeIs[int]))
|
||||
|
||||
static_assert(not is_subtype_of(TypeGuard[int], TypeGuard[bool]))
|
||||
static_assert(not is_subtype_of(TypeIs[bool], TypeIs[int]))
|
||||
static_assert(not is_subtype_of(TypeIs[int], TypeIs[bool]))
|
||||
```
|
||||
|
||||
### Module literals
|
||||
|
||||
```py
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue