Skip to content

bpo-43766: Implement PEP 647 (User-Defined Type Guards) in typing.py #25282

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

Merged
merged 13 commits into from
Apr 27, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions Doc/library/typing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,80 @@ These can be used as types in annotations using ``[]``, each having a unique syn

.. versionadded:: 3.9


.. data:: TypeGuard

Special typing form 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.

``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"::

def is_str(val: Union[str, float]):
# "isinstance" type guard
if isinstance(val, str):
# Type of ``val`` is narrowed to ``str``
...
else:
# Else, type of ``val`` is narrowed to ``float``.
...

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.

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

For example::

def is_str_list(val: List[object]) -> TypeGuard[List[str]]:
'''Determines whether all objects in the list are strings'''
return all(isinstance(x, str) for x in val)

def func1(val: List[object]):
if is_str_list(val):
# Type of ``val`` is narrowed to List[str]
print(" ".join(val))
else:
# 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. Even if
the type guard function passes type checks, it may still fail at runtime.
The type guard function may perform erroneous checks and return wrong
booleans. Consequently, the type it promises in ``TypeGuard[TypeB]`` may
not hold.

``TypeGuard`` also works with type variables. For more information, see
:pep:`647` (User-Defined Type Guards).

.. versionadded:: 3.10


Building generic types
""""""""""""""""""""""

Expand Down
10 changes: 10 additions & 0 deletions Doc/whatsnew/3.10.rst
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,16 @@ See :pep:`613` for more details.

(Contributed by Mikhail Golubev in :issue:`41923`.)

PEP 647: User-Defined Type Guards
---------------------------------

:data:`TypeGuard` has been added to the :mod:`typing` module to annotate
type guard functions and improve information provided to static type checkers
during type narrowing. For more information, please see :data:`TypeGuard`\ 's
documentation, and :pep:`647`.

(Contributed by Ken Jin and Guido van Rossum in :issue:`43766`.
PEP written by Eric Traut.)

Other Language Changes
======================
Expand Down
40 changes: 40 additions & 0 deletions Lib/test/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from typing import Annotated, ForwardRef
from typing import TypeAlias
from typing import ParamSpec, Concatenate, ParamSpecArgs, ParamSpecKwargs
from typing import TypeGuard
import abc
import typing
import weakref
Expand Down Expand Up @@ -4364,6 +4365,45 @@ def test_valid_uses(self):
self.assertEqual(C4.__parameters__, (T, P))


class TypeGuardTests(BaseTestCase):
def test_basics(self):
TypeGuard[int] # OK

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

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

def test_cannot_subclass(self):
with self.assertRaises(TypeError):
class C(type(TypeGuard)):
pass
with self.assertRaises(TypeError):
class C(type(TypeGuard[int])):
pass

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

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


class AllTests(BaseTestCase):
"""Tests for __all__."""

Expand Down
49 changes: 49 additions & 0 deletions Lib/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
'Text',
'TYPE_CHECKING',
'TypeAlias',
'TypeGuard',
]

# The pseudo-submodules 're' and 'io' are part of the public
Expand Down Expand Up @@ -568,6 +569,54 @@ def Concatenate(self, parameters):
return _ConcatenateGenericAlias(self, parameters)


@_SpecialForm
def TypeGuard(self, parameters):
"""Special typing form 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.

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

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.

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

For example::

def is_str(val: Union[str, float]):
# "isinstance" type guard
if isinstance(val, str):
# Type of ``val`` is narrowed to ``str``
...
else:
# Else, type of ``val`` is narrowed to ``float``.
...

Strict type narrowing is not enforced -- ``TypeB`` need not be a narrower
form of ``TypeA`` (it can even be a wider form) and this may lead to
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.

``TypeGuard`` also works with type variables. For more information, see
PEP 647 (User-Defined Type Guards).
"""
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."""

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Implement :pep:`647` in the :mod:`typing` module by adding
:data:`TypeGuard`.