Skip to content

Commit

Permalink
gh-117516: Implement typing.TypeIs (#117517)
Browse files Browse the repository at this point in the history
See PEP 742.

Co-authored-by: Carl Meyer <carl@oddbird.net>
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
  • Loading branch information
3 people committed Apr 9, 2024
1 parent 5718324 commit f2132fc
Show file tree
Hide file tree
Showing 5 changed files with 236 additions and 39 deletions.
118 changes: 91 additions & 27 deletions Doc/library/typing.rst
Expand Up @@ -1385,22 +1385,23 @@ These can be used as types in annotations. They all support subscription using
.. versionadded:: 3.9


.. data:: TypeGuard
.. data:: TypeIs

Special typing construct for marking user-defined type guard functions.
Special typing construct for marking user-defined type predicate functions.

``TypeGuard`` can be used to annotate the return type of a user-defined
type guard function. ``TypeGuard`` only accepts a single type argument.
At runtime, functions marked this way should return a boolean.
``TypeIs`` can be used to annotate the return type of a user-defined
type predicate function. ``TypeIs`` only accepts a single type argument.
At runtime, functions marked this way should return a boolean and take at
least one positional argument.

``TypeGuard`` aims to benefit *type narrowing* -- a technique used by static
``TypeIs`` aims to benefit *type narrowing* -- a technique used by static
type checkers to determine a more precise type of an expression within a
program's code flow. Usually type narrowing is done by analyzing
conditional code flow and applying the narrowing to a block of code. The
conditional expression here is sometimes referred to as a "type guard"::
conditional expression here is sometimes referred to as a "type predicate"::

def is_str(val: str | float):
# "isinstance" type guard
# "isinstance" type predicate
if isinstance(val, str):
# Type of ``val`` is narrowed to ``str``
...
Expand All @@ -1409,8 +1410,73 @@ These can be used as types in annotations. They all support subscription using
...

Sometimes it would be convenient to use a user-defined boolean function
as a type guard. Such a function should use ``TypeGuard[...]`` as its
return type to alert static type checkers to this intention.
as a type predicate. Such a function should use ``TypeIs[...]`` or
:data:`TypeGuard` as its return type to alert static type checkers to
this intention. ``TypeIs`` usually has more intuitive behavior than
``TypeGuard``, but it cannot be used when the input and output types
are incompatible (e.g., ``list[object]`` to ``list[int]``) or when the
function does not return ``True`` for all instances of the narrowed type.

Using ``-> TypeIs[NarrowedType]`` tells the static type checker that for a given
function:

1. The return value is a boolean.
2. If the return value is ``True``, the type of its argument
is the intersection of the argument's original type and ``NarrowedType``.
3. If the return value is ``False``, the type of its argument
is narrowed to exclude ``NarrowedType``.

For example::

from typing import assert_type, final, TypeIs

class Parent: pass
class Child(Parent): pass
@final
class Unrelated: pass

def is_parent(val: object) -> TypeIs[Parent]:
return isinstance(val, Parent)

def run(arg: Child | Unrelated):
if is_parent(arg):
# Type of ``arg`` is narrowed to the intersection
# of ``Parent`` and ``Child``, which is equivalent to
# ``Child``.
assert_type(arg, Child)
else:
# Type of ``arg`` is narrowed to exclude ``Parent``,
# so only ``Unrelated`` is left.
assert_type(arg, Unrelated)

The type inside ``TypeIs`` must be consistent with the type of the
function's argument; if it is not, static type checkers will raise
an error. An incorrectly written ``TypeIs`` function can lead to
unsound behavior in the type system; it is the user's responsibility
to write such functions in a type-safe manner.

If a ``TypeIs`` function is a class or instance method, then the type in
``TypeIs`` maps to the type of the second parameter after ``cls`` or
``self``.

In short, the form ``def foo(arg: TypeA) -> TypeIs[TypeB]: ...``,
means that if ``foo(arg)`` returns ``True``, then ``arg`` is an instance
of ``TypeB``, and if it returns ``False``, it is not an instance of ``TypeB``.

``TypeIs`` also works with type variables. For more information, see
:pep:`742` (Narrowing types with ``TypeIs``).

.. versionadded:: 3.13


.. data:: TypeGuard

Special typing construct for marking user-defined type predicate functions.

Type predicate functions are user-defined functions that return whether their
argument is an instance of a particular type.
``TypeGuard`` works similarly to :data:`TypeIs`, but has subtly different
effects on type checking behavior (see below).

Using ``-> TypeGuard`` tells the static type checker that for a given
function:
Expand All @@ -1419,6 +1485,8 @@ These can be used as types in annotations. They all support subscription using
2. If the return value is ``True``, the type of its argument
is the type inside ``TypeGuard``.

``TypeGuard`` also works with type variables. See :pep:`647` for more details.

For example::

def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
Expand All @@ -1433,23 +1501,19 @@ These can be used as types in annotations. They all support subscription using
# Type of ``val`` remains as ``list[object]``.
print("Not a list of strings!")

If ``is_str_list`` is a class or instance method, then the type in
``TypeGuard`` maps to the type of the second parameter after ``cls`` or
``self``.

In short, the form ``def foo(arg: TypeA) -> TypeGuard[TypeB]: ...``,
means that if ``foo(arg)`` returns ``True``, then ``arg`` narrows from
``TypeA`` to ``TypeB``.

.. note::

``TypeB`` need not be a narrower form of ``TypeA`` -- it can even be a
wider form. The main reason is to allow for things like
narrowing ``list[object]`` to ``list[str]`` even though the latter
is not a subtype of the former, since ``list`` is invariant.
The responsibility of writing type-safe type guards is left to the user.

``TypeGuard`` also works with type variables. See :pep:`647` for more details.
``TypeIs`` and ``TypeGuard`` differ in the following ways:

* ``TypeIs`` requires the narrowed type to be a subtype of the input type, while
``TypeGuard`` does not. The main reason is to allow for things like
narrowing ``list[object]`` to ``list[str]`` even though the latter
is not a subtype of the former, since ``list`` is invariant.
* When a ``TypeGuard`` function returns ``True``, type checkers narrow the type of the
variable to exactly the ``TypeGuard`` type. When a ``TypeIs`` function returns ``True``,
type checkers can infer a more precise type combining the previously known type of the
variable with the ``TypeIs`` type. (Technically, this is known as an intersection type.)
* When a ``TypeGuard`` function returns ``False``, type checkers cannot narrow the type of
the variable at all. When a ``TypeIs`` function returns ``False``, type checkers can narrow
the type of the variable to exclude the ``TypeIs`` type.

.. versionadded:: 3.10

Expand Down
4 changes: 4 additions & 0 deletions Doc/whatsnew/3.13.rst
Expand Up @@ -87,6 +87,10 @@ Interpreter improvements:
Performance improvements are modest -- we expect to be improving this
over the next few releases.

New typing features:

* :pep:`742`: :data:`typing.TypeIs` was added, providing more intuitive
type narrowing behavior.

New Features
============
Expand Down
56 changes: 55 additions & 1 deletion Lib/test/test_typing.py
Expand Up @@ -38,7 +38,7 @@
from typing import Self, LiteralString
from typing import TypeAlias
from typing import ParamSpec, Concatenate, ParamSpecArgs, ParamSpecKwargs
from typing import TypeGuard
from typing import TypeGuard, TypeIs
import abc
import textwrap
import typing
Expand Down Expand Up @@ -5207,6 +5207,7 @@ def test_subclass_special_form(self):
Literal[1, 2],
Concatenate[int, ParamSpec("P")],
TypeGuard[int],
TypeIs[range],
):
with self.subTest(msg=obj):
with self.assertRaisesRegex(
Expand Down Expand Up @@ -6748,6 +6749,7 @@ class C(Generic[T]): pass
self.assertEqual(get_args(NotRequired[int]), (int,))
self.assertEqual(get_args(TypeAlias), ())
self.assertEqual(get_args(TypeGuard[int]), (int,))
self.assertEqual(get_args(TypeIs[range]), (range,))
Ts = TypeVarTuple('Ts')
self.assertEqual(get_args(Ts), ())
self.assertEqual(get_args((*Ts,)[0]), (Ts,))
Expand Down Expand Up @@ -9592,6 +9594,56 @@ def test_no_isinstance(self):
issubclass(int, TypeGuard)


class TypeIsTests(BaseTestCase):
def test_basics(self):
TypeIs[int] # OK

def foo(arg) -> TypeIs[int]: ...
self.assertEqual(gth(foo), {'return': TypeIs[int]})

with self.assertRaises(TypeError):
TypeIs[int, str]

def test_repr(self):
self.assertEqual(repr(TypeIs), 'typing.TypeIs')
cv = TypeIs[int]
self.assertEqual(repr(cv), 'typing.TypeIs[int]')
cv = TypeIs[Employee]
self.assertEqual(repr(cv), 'typing.TypeIs[%s.Employee]' % __name__)
cv = TypeIs[tuple[int]]
self.assertEqual(repr(cv), 'typing.TypeIs[tuple[int]]')

def test_cannot_subclass(self):
with self.assertRaisesRegex(TypeError, CANNOT_SUBCLASS_TYPE):
class C(type(TypeIs)):
pass
with self.assertRaisesRegex(TypeError, CANNOT_SUBCLASS_TYPE):
class D(type(TypeIs[int])):
pass
with self.assertRaisesRegex(TypeError,
r'Cannot subclass typing\.TypeIs'):
class E(TypeIs):
pass
with self.assertRaisesRegex(TypeError,
r'Cannot subclass typing\.TypeIs\[int\]'):
class F(TypeIs[int]):
pass

def test_cannot_init(self):
with self.assertRaises(TypeError):
TypeIs()
with self.assertRaises(TypeError):
type(TypeIs)()
with self.assertRaises(TypeError):
type(TypeIs[Optional[int]])()

def test_no_isinstance(self):
with self.assertRaises(TypeError):
isinstance(1, TypeIs[int])
with self.assertRaises(TypeError):
issubclass(int, TypeIs)


SpecialAttrsP = typing.ParamSpec('SpecialAttrsP')
SpecialAttrsT = typing.TypeVar('SpecialAttrsT', int, float, complex)

Expand Down Expand Up @@ -9691,6 +9743,7 @@ def test_special_attrs(self):
typing.Optional: 'Optional',
typing.TypeAlias: 'TypeAlias',
typing.TypeGuard: 'TypeGuard',
typing.TypeIs: 'TypeIs',
typing.TypeVar: 'TypeVar',
typing.Union: 'Union',
typing.Self: 'Self',
Expand All @@ -9705,6 +9758,7 @@ def test_special_attrs(self):
typing.Literal[True, 2]: 'Literal',
typing.Optional[Any]: 'Optional',
typing.TypeGuard[Any]: 'TypeGuard',
typing.TypeIs[Any]: 'TypeIs',
typing.Union[Any]: 'Any',
typing.Union[int, float]: 'Union',
# Incompatible special forms (tested in test_special_attrs2)
Expand Down

1 comment on commit f2132fc

@bswck
Copy link

@bswck bswck commented on f2132fc Apr 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉🎉🎉

Please sign in to comment.