Skip to content
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

PEP 724: Stricter TypeGuard #3266

Merged
merged 83 commits into from
Sep 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
f5205c6
Update after Erik's feedback
rchiodo Aug 2, 2023
9da9233
Erik's initial comments
rchiodo Aug 2, 2023
00d5247
Update after Erik's feedback
rchiodo Aug 2, 2023
79720d5
Merge branch 'rchiodo/pep-722-strictertypeguard' of https://github.co…
rchiodo Aug 2, 2023
4c4a0e9
Update pep-0722.rst
rchiodo Aug 3, 2023
4f90e9c
Add the deliberate mistake case
rchiodo Aug 3, 2023
faa5768
Merge branch 'rchiodo/pep-722-strictertypeguard' of https://github.co…
rchiodo Aug 3, 2023
f00e7a2
Update pep-0722.rst
rchiodo Aug 3, 2023
c895a0a
Update pep-0722.rst
rchiodo Aug 3, 2023
5305af4
Update pep-0722.rst
rchiodo Aug 3, 2023
b6335f0
Update pep-0722.rst
rchiodo Aug 3, 2023
f24ca7c
Update pep-0722.rst
rchiodo Aug 3, 2023
cbf907a
More review feedback
rchiodo Aug 3, 2023
dea607f
Merge branch 'rchiodo/pep-722-strictertypeguard' of https://github.co…
rchiodo Aug 3, 2023
e5a71ea
More review feedback
rchiodo Aug 3, 2023
a32efa4
Fix 80 column limit
rchiodo Aug 3, 2023
708f60c
Update pep-0722.rst
rchiodo Aug 3, 2023
662341f
Update pep-0722.rst
rchiodo Aug 3, 2023
24b88ba
More feedback
rchiodo Aug 3, 2023
a682219
Review feedback
rchiodo Aug 3, 2023
6287be5
Some more subtle word changes
rchiodo Aug 3, 2023
a2e0b22
Change verbiage a little more
rchiodo Aug 3, 2023
66bdf14
Merge pull request #1 from rchiodo/rchiodo/pep-722-strictertypeguard
rchiodo Aug 4, 2023
5850d66
Change pep number
rchiodo Aug 4, 2023
31fb9c7
Merge branch 'rchiodo/pep-722' of https://github.com/rchiodo/peps int…
rchiodo Aug 4, 2023
641b761
Update PEP number
rchiodo Aug 4, 2023
1ec2207
Fix some errors in the pre-commit
rchiodo Aug 4, 2023
ce63130
Merge branch 'main' into rchiodo/pep_725
rchiodo Aug 4, 2023
6184c24
Review feedback
rchiodo Aug 4, 2023
00e028c
Merge branch 'rchiodo/pep_725' of https://github.com/rchiodo/peps int…
rchiodo Aug 4, 2023
7ab28d9
Update codeowners
rchiodo Aug 4, 2023
dddc7cb
Remove ``Resolution``
AA-Turner Aug 4, 2023
7e51159
Move to specification
rchiodo Aug 4, 2023
68e73a3
Merge branch 'rchiodo/pep_725' of https://github.com/rchiodo/peps int…
rchiodo Aug 4, 2023
a9b0407
Update pep-0724.rst
rchiodo Aug 4, 2023
106f10a
Formatting and grammar
rchiodo Aug 4, 2023
e44d060
Merge branch 'rchiodo/pep_725' of https://github.com/rchiodo/peps int…
rchiodo Aug 4, 2023
07af8f7
Update pep-0724.rst
rchiodo Aug 4, 2023
81bc56c
More review feedback
rchiodo Aug 4, 2023
535ab81
Fix footnotes
rchiodo Aug 4, 2023
7b69948
Update based on Eric's feedback
rchiodo Aug 4, 2023
ddc4ec3
Fix build warnings
rchiodo Aug 4, 2023
dd4bf80
Fixup comment
rchiodo Aug 4, 2023
5eab931
Add some more examples
rchiodo Aug 5, 2023
c5449b8
Rework 'backwards compatibility'
rchiodo Aug 5, 2023
6ff5be9
Grammar
rchiodo Aug 5, 2023
487a47e
Remove ``Resolution``
AA-Turner Aug 5, 2023
4f9753a
Update pep-0724.rst
rchiodo Aug 7, 2023
3c37ec3
Update pep-0724.rst
rchiodo Aug 7, 2023
27bac54
Merge branch 'main' into rchiodo/pep_725
rchiodo Aug 7, 2023
b90df2c
Fix column width
rchiodo Aug 7, 2023
3263b63
Another try for backwards compatibility
rchiodo Aug 8, 2023
dcf3d49
another blurb about backwards compatibility
rchiodo Aug 8, 2023
598d0da
Merge remote-tracking branch 'upstream/main' into rchiodo/pep_725
rchiodo Aug 25, 2023
a580f4a
Add mypy primer test
rchiodo Aug 25, 2023
95d4616
Add another rejected idea
rchiodo Aug 25, 2023
b267456
Fix typo
rchiodo Aug 28, 2023
6b246f2
Update based on discussions with Eric
rchiodo Aug 30, 2023
0e24a6f
Remove unnecessary lines and incorrect statement about covariance
rchiodo Aug 31, 2023
457ea0e
Merge branch 'main' into rchiodo/pep_725
rchiodo Aug 31, 2023
5ff0408
Use 'math' to specify logic
rchiodo Sep 1, 2023
b91a5ca
Merge branch 'main' into rchiodo/pep_725
rchiodo Sep 1, 2023
d8ea2cc
Don't reference intersections as a future PEP
rchiodo Sep 1, 2023
671df1f
Extra line
rchiodo Sep 1, 2023
d3048de
Improve wording
rchiodo Sep 1, 2023
4a7039e
Move degenerate case
rchiodo Sep 5, 2023
e3fd558
Some formatting
rchiodo Sep 5, 2023
bccb5fb
Merge branch 'main' into rchiodo/pep_725
rchiodo Sep 5, 2023
e5f4749
Update pep-0724.rst
JelleZijlstra Sep 8, 2023
25fc119
Merge remote-tracking branch 'upstream/main' into rchiodo/pep_725
AA-Turner Sep 9, 2023
44a31ae
Move to ``peps/``
AA-Turner Sep 9, 2023
93b1503
Whitespace
AA-Turner Sep 9, 2023
499639f
Add trailing comma
AA-Turner Sep 9, 2023
b181ef9
Copyright formatting
AA-Turner Sep 9, 2023
5963e45
Update peps/pep-0724.rst
rchiodo Sep 11, 2023
867c541
Update peps/pep-0724.rst
rchiodo Sep 11, 2023
2a2336a
Update peps/pep-0724.rst
rchiodo Sep 11, 2023
dd5fdee
More review feedback
rchiodo Sep 11, 2023
92768e2
Updates from Eric Traut
rchiodo Sep 15, 2023
159c519
Fix build and linter
rchiodo Sep 15, 2023
8f28e7a
Merge branch 'main' into rchiodo/pep_725
rchiodo Sep 15, 2023
678ce5f
Try fixing post history
rchiodo Sep 15, 2023
f817e07
Fix post history again
rchiodo Sep 15, 2023
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
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ peps/pep-0720.rst @FFY00
peps/pep-0721.rst @encukou
peps/pep-0722.rst @pfmoore
peps/pep-0723.rst @AA-Turner
peps/pep-0724.rst @jellezijlstra
peps/pep-0725.rst @pradyunsg
peps/pep-0726.rst @AA-Turner
peps/pep-0727.rst @JelleZijlstra
Expand Down
331 changes: 331 additions & 0 deletions peps/pep-0724.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
PEP: 724
Title: Stricter Type Guards
Author: Rich Chiodo <rchiodo at microsoft.com>,
Eric Traut <erictr at microsoft.com>,
Erik De Bonte <erikd at microsoft.com>,
Sponsor: Jelle Zijlstra <[email protected]>
Discussions-To: https://mail.python.org/archives/list/[email protected]/thread/7KZ2VUDXZ5UKAUHRNXBJYBENAYMT6WXN/
Status: Draft
Type: Standards Track
Topic: Typing
Content-Type: text/x-rst
Created: 28-Jul-2023
Python-Version: 3.13
Post-History: `30-Dec-2021 <https://mail.python.org/archives/list/[email protected]/thread/EMUD2D424OI53DCWQ4H5L6SJD2IXBHUL/>`__


Abstract
========

:pep:`647` introduced the concept of a user-defined type guard function which
returns ``True`` if the type of the expression passed to its first parameter
matches its return ``TypeGuard`` type. For example, a function that has a
return type of ``TypeGuard[str]`` is assumed to return ``True`` if and only if
the type of the expression passed to its first input parameter is a ``str``.
This allows type checkers to narrow types when a user-defined type guard
function returns ``True``.

This PEP refines the ``TypeGuard`` mechanism introduced in :pep:`647`. It
allows type checkers to narrow types when a user-defined type guard function
returns ``False``. It also allows type checkers to apply additional (more
precise) type narrowing under certain circumstances when the type guard
function returns ``True``.


Motivation
==========

User-defined type guard functions enable a type checker to narrow the type of
an expression when it is passed as an argument to the type guard function. The
``TypeGuard`` mechanism introduced in :pep:`647` is flexible, but this
flexibility imposes some limitations that developers have found inconvenient
for some uses.

Limitation 1: Type checkers are not allowed to narrow a type in the case where
the type guard function returns ``False``. This means the type is not narrowed
in the negative ("else") clause.

Limitation 2: Type checkers must use the ``TypeGuard`` return type if the type
guard function returns ``True`` regardless of whether additional narrowing can
be applied based on knowledge of the pre-narrowed type.

The following code sample demonstrates both of these limitations.

.. code-block:: python

def is_iterable(val: object) -> TypeGuard[Iterable[Any]]:
return isinstance(val, Iterable)

def func(val: int | list[int]):
if is_iterable(val):
# The type is narrowed to 'Iterable[Any]' as dictated by
# the TypeGuard return type
reveal_type(val) # Iterable[Any]
else:
# The type is not narrowed in the "False" case
reveal_type(val) # int | list[int]

# If "isinstance" is used in place of the user-defined type guard
# function, the results differ because type checkers apply additional
# logic for "isinstance"

if isinstance(val, Iterable):
# Type is narrowed to "list[int]" because this is
# a narrower (more precise) type than "Iterable[Any]"
reveal_type(val) # list[int]
else:
# Type is narrowed to "int" because the logic eliminates
# "list[int]" from the original union
reveal_type(val) # int


:pep:`647` imposed these limitations so it could support use cases where the
return ``TypeGuard`` type was not a subtype of the input type. Refer to
:pep:`647` for examples.


Specification
=============

The use of a user-defined type guard function involves five types:

* I = ``TypeGuard`` input type
* R = ``TypeGuard`` return type
* A = Type of argument passed to type guard function (pre-narrowed)
* NP = Narrowed type (positive)
* NN = Narrowed type (negative)

.. code-block:: python

def guard(x: I) -> TypeGuard[R]: ...

def func1(val: A):
if guard(val):
reveal_type(val) # NP
else:
reveal_type(val) # NN


This PEP proposes some modifications to :pep:`647` to address the limitations
discussed above. These limitations are safe to eliminate only when a specific
condition is met. In particular, when the output type ``R`` of a user-defined
type guard function is consistent [#isconsistent]_ with the type of its first
input parameter (``I``), type checkers should apply stricter type guard
semantics.

.. code-block:: python

# Stricter type guard semantics are used in this case because
# "Kangaroo | Koala" is consistent with "Animal"
def is_marsupial(val: Animal) -> TypeGuard[Kangaroo | Koala]:
return isinstance(val, Kangaroo | Koala)

# Stricter type guard semantics are not used in this case because
# "list[T]"" is not consistent with "list[T | None]"
def has_no_nones(val: list[T | None]) -> TypeGuard[list[T]]:
return None not in val

When stricter type guard semantics are applied, the application of a
user-defined type guard function changes in two ways.

* Type narrowing is applied in the negative ("else") case.

.. code-block:: python

def is_str(val: str | int) -> TypeGuard[str]:
return isinstance(val, str)

def func(val: str | int):
if not is_str(val):
reveal_type(val) # int

* Additional type narrowing is applied in the positive "if" case if applicable.

.. code-block:: python

def is_cardinal_direction(val: str) -> TypeGuard[Literal["N", "S", "E", "W"]]:
return val in ("N", "S", "E", "W")

def func(direction: Literal["NW", "E"]):
if is_cardinal_direction(direction):
reveal_type(direction) # "Literal[E]"
else:
reveal_type(direction) # "Literal[NW]"


The type-theoretic rules for type narrowing are specificed in the following
table.

============ ======================= ===================
\ Non-strict type guard Strict type guard
============ ======================= ===================
Applies when R not consistent with I R consistent with I
NP is .. :math:`R` :math:`A \land R`
NN is .. :math:`A` :math:`A \land \neg{R}`
============ ======================= ===================

In practice, the theoretic types for strict type guards cannot be expressed
precisely in the Python type system. Type checkers should fall back on
practical approximations of these types. As a rule of thumb, a type checker
should use the same type narrowing logic -- and get results that are consistent
with -- its handling of "isinstance". This guidance allows for changes and
improvements if the type system is extended in the future.


Additional Examples
===================

``Any`` is consistent [#isconsistent]_ with any other type, which means
stricter semantics can be applied.

.. code-block:: python

# Stricter type guard semantics are used in this case because
# "str" is consistent with "Any"
def is_str(x: Any) -> TypeGuard[str]:
return isinstance(x, str)

def test(x: float | str):
if is_str(x):
reveal_type(x) # str
else:
reveal_type(x) # float


Backwards Compatibility
=======================

This PEP proposes to change the existing behavior of ``TypeGuard``. This has no
effect at runtime, but it does change the types evaluated by a type checker.

.. code-block:: python

def is_int(val: int | str) -> TypeGuard[int]:
return isinstance(val, int)

def func(val: int | str):
if is_int(val):
reveal_type(val) # "int"
else:
reveal_type(val) # Previously "int | str", now "str"


This behavioral change results in different types evaluated by a type checker.
It could therefore produce new (or mask existing) type errors.

Type checkers often improve narrowing logic or fix existing bugs in such logic,
so users of static typing will be used to this type of behavioral change.

We also hypothesize that it is unlikely that existing typed Python code relies
on the current behavior of ``TypeGuard``. To validate our hypothesis, we
implemented the proposed change in pyright and ran this modified version on
roughly 25 typed code bases using `mypy primer`__ to see if there were any
differences in the output. As predicted, the behavioral change had minimal
impact. The only noteworthy change was that some ``# type: ignore`` comments
were no longer necessary, indicating that these code bases were already working
around the existing limitations of ``TypeGuard``.

__ https://github.com/hauntsaninja/mypy_primer

Breaking change
---------------

It is possible for a user-defined type guard function to rely on the old
behavior. Such type guard functions could break with the new behavior.

.. code-block:: python

def is_positive_int(val: int | str) -> TypeGuard[int]:
return isinstance(val, int) and val > 0

def func(val: int | str):
if is_positive_int(val):
reveal_type(val) # "int"
else:
# With the older behavior, the type of "val" is evaluated as
# "int | str"; with the new behavior, the type is narrowed to
# "str", which is perhaps not what was intended.
reveal_type(val)

We think it is unlikley that such user-defined type guards exist in real-world
code. The mypy primer results didn't uncover any such cases.


How to Teach This
=================

Users unfamiliar with ``TypeGuard`` are likely to expect the behavior outlined
in this PEP, therefore making ``TypeGuard`` easier to teach and explain.


Reference Implementation
========================

A reference `implementation`__ of this idea exists in pyright.

__ https://github.com/microsoft/pyright/commit/9a5af798d726bd0612cebee7223676c39cf0b9b0

To enable the modified behavior, the configuration flag
``enableExperimentalFeatures`` must be set to true. This can be done on a
per-file basis by adding a comment:

.. code-block:: python

# pyright: enableExperimentalFeatures=true


Rejected Ideas
==============

StrictTypeGuard
---------------

A new ``StrictTypeGuard`` construct was proposed. This alternative form would
be similar to a ``TypeGuard`` except it would apply stricter type guard
semantics. It would also enforce that the return type was consistent
[#isconsistent]_ with the input type. See this thread for details:
`StrictTypeGuard proposal`__

__ https://github.com/python/typing/discussions/1013#discussioncomment-1966238

This idea was rejected because it is unnecessary in most cases and added
unnecessary complexity. It would require the introduction of a new special
form, and developers would need to be educated about the subtle difference
between the two forms.

TypeGuard with a second output type
-----------------------------------

Another idea was proposed where ``TypeGuard`` could support a second optional
type argument that indicates the type that should be used for narrowing in the
negative ("else") case.

.. code-block:: python

def is_int(val: int | str) -> TypeGuard[int, str]:
return isinstance(val, int)


This idea was proposed `here`__.

__ https://github.com/python/typing/issues/996

It was rejected because it was considered too complicated and addressed only
one of the two main limitations of ``TypeGuard``. Refer to this `thread`__ for
the full discussion.

__ https://mail.python.org/archives/list/[email protected]/thread/EMUD2D424OI53DCWQ4H5L6SJD2IXBHUL


Footnotes
=========

.. [#isconsistent] :pep:`PEP 483's discussion of is-consistent <483#summary-of-gradual-typing>`


Copyright
=========

This document is placed in the public domain or under the
CC0-1.0-Universal license, whichever is more permissive.