Skip to content

[ty] Add partial support for TypeIs #18294

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 12 commits into from
174 changes: 120 additions & 54 deletions crates/ty/docs/rules.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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`)]
Expand Down
264 changes: 264 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
# 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[a, str]`
reveal_type(f(a)) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(g(a)) # revealed: TypeIs[a, str]
```

## 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]
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

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: Is this error correct?
if g(*d): # error: [missing-argument]
...

if f("foo"): # TODO: error: [invalid-type-guard-call]
...

if g(a=d): # error: [invalid-type-guard-call]
...

def _(a: tuple[str, int] | tuple[int, str]):
if g(a[0]):
# TODO: Should be `tuple[str, int]`
reveal_type(a) # revealed: tuple[str, int] | tuple[int, str]
```

## 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

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

def _(a: str | int):
b = guard_str(a)
c = is_int(a)

reveal_type(a) # revealed: str | int
# TODO: Should be `TypeGuard[a, str]`
reveal_type(b) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(c) # revealed: TypeIs[a, int]

if b:
# TODO: Should be `str`
reveal_type(a) # revealed: str | int
else:
reveal_type(a) # revealed: str | int

if c:
reveal_type(a) # revealed: int
else:
reveal_type(a) # revealed: str & ~int

def _(x: str | int, flag: bool) -> None:
b = is_int(x)
reveal_type(b) # revealed: TypeIs[x, int]

if flag:
x = ""

if b:
# TODO: Should be `str | int`
reveal_type(x) # revealed: 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
```
Original file line number Diff line number Diff line change
Expand Up @@ -789,4 +789,17 @@ 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

# TODO: TypeGuard
# static_assert(is_assignable_to(TypeGuard[Unknown], bool))
static_assert(is_assignable_to(TypeIs[Any], bool))
```

[typing documentation]: https://typing.python.org/en/latest/spec/concepts.html#the-assignable-to-or-consistent-subtyping-relation
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,17 @@ 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 TypeIs

# TODO: TypeGuard
# static_assert(not is_disjoint_from(bool, TypeGuard[str]))
static_assert(not is_disjoint_from(bool, TypeIs[str]))
```

## Callables

No two callable types are disjoint because there exists a non-empty callable type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,35 @@ 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`

`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(TypeIs[str], bool))
```

`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(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
Expand Down
Loading
Loading