Mailing List Archive

gh-117516: Implement typing.TypeIs (#117517)
https://github.com/python/cpython/commit/f2132fcd2a6da7b2b86e80189fa009ce1d2c753b
commit: f2132fcd2a6da7b2b86e80189fa009ce1d2c753b
branch: main
author: Jelle Zijlstra <jelle.zijlstra@gmail.com>
committer: JelleZijlstra <jelle.zijlstra@gmail.com>
date: 2024-04-09T10:50:37Z
summary:

gh-117516: Implement typing.TypeIs (#117517)

See PEP 742.

Co-authored-by: Carl Meyer <carl@oddbird.net>
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>

files:
A Misc/NEWS.d/next/Library/2024-04-03-16-01-31.gh-issue-117516.7DlHje.rst
M Doc/library/typing.rst
M Doc/whatsnew/3.13.rst
M Lib/test/test_typing.py
M Lib/typing.py

diff --git a/Doc/library/typing.rst b/Doc/library/typing.rst
index 73214e18d556b2..19dbd376c80d51 100644
--- a/Doc/library/typing.rst
+++ b/Doc/library/typing.rst
@@ -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``
...
@@ -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:
@@ -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]]:
@@ -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

diff --git a/Doc/whatsnew/3.13.rst b/Doc/whatsnew/3.13.rst
index 72b3a4c951eda6..707dcaa160d653 100644
--- a/Doc/whatsnew/3.13.rst
+++ b/Doc/whatsnew/3.13.rst
@@ -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
============
diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py
index 927f74eb69fbc7..bae0a8480b994f 100644
--- a/Lib/test/test_typing.py
+++ b/Lib/test/test_typing.py
@@ -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
@@ -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(
@@ -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,))
@@ -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)

@@ -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',
@@ -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)
diff --git a/Lib/typing.py b/Lib/typing.py
index d8e4ee3635994c..231492cdcc01cf 100644
--- a/Lib/typing.py
+++ b/Lib/typing.py
@@ -153,6 +153,7 @@
'TYPE_CHECKING',
'TypeAlias',
'TypeGuard',
+ 'TypeIs',
'TypeAliasType',
'Unpack',
]
@@ -818,28 +819,31 @@ def Concatenate(self, parameters):

@_SpecialForm
def TypeGuard(self, parameters):
- """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.
+ type predicate function. ``TypeGuard`` only accepts a single type argument.
At runtime, functions marked this way should return a boolean.

``TypeGuard`` 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".

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 ``TypeGuard[...]`` or
+ ``TypeIs[...]`` as its return type to alert static type checkers to
+ this intention. ``TypeGuard`` should be used over ``TypeIs`` when narrowing
+ from an incompatible type (e.g., ``list[object]`` to ``list[int]``) or when
+ the function does not return ``True`` for all instances of the narrowed type.

- Using ``-> TypeGuard`` tells the static type checker that for a given
- function:
+ Using ``-> TypeGuard[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 type inside ``TypeGuard``.
+ is ``NarrowedType``.

For example::

@@ -860,7 +864,7 @@ def func1(val: list[object]):
type-unsafe results. 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.
+ writing type-safe type predicates is left to the user.

``TypeGuard`` also works with type variables. For more information, see
PEP 647 (User-Defined Type Guards).
@@ -869,6 +873,75 @@ def func1(val: list[object]):
return _GenericAlias(self, (item,))


+@_SpecialForm
+def TypeIs(self, parameters):
+ """Special typing construct for marking user-defined type predicate functions.
+
+ ``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 accept
+ at least one argument.
+
+ ``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 predicate".
+
+ Sometimes it would be convenient to use a user-defined boolean function
+ as a type predicate. Such a function should use ``TypeIs[...]`` or
+ ``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.
+
+ ``TypeIs`` also works with type variables. For more information, see
+ PEP 742 (Narrowing types with ``TypeIs``).
+ """
+ item = _type_check(parameters, f'{self} accepts only single type.')
+ return _GenericAlias(self, (item,))
+
+
class ForwardRef(_Final, _root=True):
"""Internal wrapper to hold a forward reference."""

@@ -1241,11 +1314,12 @@ class _GenericAlias(_BaseGenericAlias, _root=True):
# A = Callable[[], None] # _CallableGenericAlias
# B = Callable[[T], None] # _CallableGenericAlias
# C = B[int] # _CallableGenericAlias
- # * Parameterized `Final`, `ClassVar` and `TypeGuard`:
+ # * Parameterized `Final`, `ClassVar`, `TypeGuard`, and `TypeIs`:
# # All _GenericAlias
# Final[int]
# ClassVar[float]
- # TypeVar[bool]
+ # TypeGuard[bool]
+ # TypeIs[range]

def __init__(self, origin, args, *, inst=True, name=None):
super().__init__(origin, inst=inst, name=name)
diff --git a/Misc/NEWS.d/next/Library/2024-04-03-16-01-31.gh-issue-117516.7DlHje.rst b/Misc/NEWS.d/next/Library/2024-04-03-16-01-31.gh-issue-117516.7DlHje.rst
new file mode 100644
index 00000000000000..bbf69126d956d2
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2024-04-03-16-01-31.gh-issue-117516.7DlHje.rst
@@ -0,0 +1 @@
+Add :data:`typing.TypeIs`, implementing :pep:`742`. Patch by Jelle Zijlstra.

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-leave@python.org
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: list-python-checkins@lists.gossamer-threads.com