Skip to content

Commit 4830660

Browse files
rchiododebonteAA-TurnerhugovkJelleZijlstra
authored
PEP 724: Stricter TypeGuard (#3266)
Co-authored-by: Erik De Bonte <[email protected]> Co-authored-by: Adam Turner <[email protected]> Co-authored-by: Hugo van Kemenade <[email protected]> Co-authored-by: Jelle Zijlstra <[email protected]>
1 parent 94ac129 commit 4830660

File tree

2 files changed

+332
-0
lines changed

2 files changed

+332
-0
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ peps/pep-0720.rst @FFY00
602602
peps/pep-0721.rst @encukou
603603
peps/pep-0722.rst @pfmoore
604604
peps/pep-0723.rst @AA-Turner
605+
peps/pep-0724.rst @jellezijlstra
605606
peps/pep-0725.rst @pradyunsg
606607
peps/pep-0726.rst @AA-Turner
607608
peps/pep-0727.rst @JelleZijlstra

peps/pep-0724.rst

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
PEP: 724
2+
Title: Stricter Type Guards
3+
Author: Rich Chiodo <rchiodo at microsoft.com>,
4+
Eric Traut <erictr at microsoft.com>,
5+
Erik De Bonte <erikd at microsoft.com>,
6+
Sponsor: Jelle Zijlstra <[email protected]>
7+
Discussions-To: https://mail.python.org/archives/list/[email protected]/thread/7KZ2VUDXZ5UKAUHRNXBJYBENAYMT6WXN/
8+
Status: Draft
9+
Type: Standards Track
10+
Topic: Typing
11+
Content-Type: text/x-rst
12+
Created: 28-Jul-2023
13+
Python-Version: 3.13
14+
Post-History: `30-Dec-2021 <https://mail.python.org/archives/list/[email protected]/thread/EMUD2D424OI53DCWQ4H5L6SJD2IXBHUL/>`__
15+
16+
17+
Abstract
18+
========
19+
20+
:pep:`647` introduced the concept of a user-defined type guard function which
21+
returns ``True`` if the type of the expression passed to its first parameter
22+
matches its return ``TypeGuard`` type. For example, a function that has a
23+
return type of ``TypeGuard[str]`` is assumed to return ``True`` if and only if
24+
the type of the expression passed to its first input parameter is a ``str``.
25+
This allows type checkers to narrow types when a user-defined type guard
26+
function returns ``True``.
27+
28+
This PEP refines the ``TypeGuard`` mechanism introduced in :pep:`647`. It
29+
allows type checkers to narrow types when a user-defined type guard function
30+
returns ``False``. It also allows type checkers to apply additional (more
31+
precise) type narrowing under certain circumstances when the type guard
32+
function returns ``True``.
33+
34+
35+
Motivation
36+
==========
37+
38+
User-defined type guard functions enable a type checker to narrow the type of
39+
an expression when it is passed as an argument to the type guard function. The
40+
``TypeGuard`` mechanism introduced in :pep:`647` is flexible, but this
41+
flexibility imposes some limitations that developers have found inconvenient
42+
for some uses.
43+
44+
Limitation 1: Type checkers are not allowed to narrow a type in the case where
45+
the type guard function returns ``False``. This means the type is not narrowed
46+
in the negative ("else") clause.
47+
48+
Limitation 2: Type checkers must use the ``TypeGuard`` return type if the type
49+
guard function returns ``True`` regardless of whether additional narrowing can
50+
be applied based on knowledge of the pre-narrowed type.
51+
52+
The following code sample demonstrates both of these limitations.
53+
54+
.. code-block:: python
55+
56+
def is_iterable(val: object) -> TypeGuard[Iterable[Any]]:
57+
return isinstance(val, Iterable)
58+
59+
def func(val: int | list[int]):
60+
if is_iterable(val):
61+
# The type is narrowed to 'Iterable[Any]' as dictated by
62+
# the TypeGuard return type
63+
reveal_type(val) # Iterable[Any]
64+
else:
65+
# The type is not narrowed in the "False" case
66+
reveal_type(val) # int | list[int]
67+
68+
# If "isinstance" is used in place of the user-defined type guard
69+
# function, the results differ because type checkers apply additional
70+
# logic for "isinstance"
71+
72+
if isinstance(val, Iterable):
73+
# Type is narrowed to "list[int]" because this is
74+
# a narrower (more precise) type than "Iterable[Any]"
75+
reveal_type(val) # list[int]
76+
else:
77+
# Type is narrowed to "int" because the logic eliminates
78+
# "list[int]" from the original union
79+
reveal_type(val) # int
80+
81+
82+
:pep:`647` imposed these limitations so it could support use cases where the
83+
return ``TypeGuard`` type was not a subtype of the input type. Refer to
84+
:pep:`647` for examples.
85+
86+
87+
Specification
88+
=============
89+
90+
The use of a user-defined type guard function involves five types:
91+
92+
* I = ``TypeGuard`` input type
93+
* R = ``TypeGuard`` return type
94+
* A = Type of argument passed to type guard function (pre-narrowed)
95+
* NP = Narrowed type (positive)
96+
* NN = Narrowed type (negative)
97+
98+
.. code-block:: python
99+
100+
def guard(x: I) -> TypeGuard[R]: ...
101+
102+
def func1(val: A):
103+
if guard(val):
104+
reveal_type(val) # NP
105+
else:
106+
reveal_type(val) # NN
107+
108+
109+
This PEP proposes some modifications to :pep:`647` to address the limitations
110+
discussed above. These limitations are safe to eliminate only when a specific
111+
condition is met. In particular, when the output type ``R`` of a user-defined
112+
type guard function is consistent [#isconsistent]_ with the type of its first
113+
input parameter (``I``), type checkers should apply stricter type guard
114+
semantics.
115+
116+
.. code-block:: python
117+
118+
# Stricter type guard semantics are used in this case because
119+
# "Kangaroo | Koala" is consistent with "Animal"
120+
def is_marsupial(val: Animal) -> TypeGuard[Kangaroo | Koala]:
121+
return isinstance(val, Kangaroo | Koala)
122+
123+
# Stricter type guard semantics are not used in this case because
124+
# "list[T]"" is not consistent with "list[T | None]"
125+
def has_no_nones(val: list[T | None]) -> TypeGuard[list[T]]:
126+
return None not in val
127+
128+
When stricter type guard semantics are applied, the application of a
129+
user-defined type guard function changes in two ways.
130+
131+
* Type narrowing is applied in the negative ("else") case.
132+
133+
.. code-block:: python
134+
135+
def is_str(val: str | int) -> TypeGuard[str]:
136+
return isinstance(val, str)
137+
138+
def func(val: str | int):
139+
if not is_str(val):
140+
reveal_type(val) # int
141+
142+
* Additional type narrowing is applied in the positive "if" case if applicable.
143+
144+
.. code-block:: python
145+
146+
def is_cardinal_direction(val: str) -> TypeGuard[Literal["N", "S", "E", "W"]]:
147+
return val in ("N", "S", "E", "W")
148+
149+
def func(direction: Literal["NW", "E"]):
150+
if is_cardinal_direction(direction):
151+
reveal_type(direction) # "Literal[E]"
152+
else:
153+
reveal_type(direction) # "Literal[NW]"
154+
155+
156+
The type-theoretic rules for type narrowing are specificed in the following
157+
table.
158+
159+
============ ======================= ===================
160+
\ Non-strict type guard Strict type guard
161+
============ ======================= ===================
162+
Applies when R not consistent with I R consistent with I
163+
NP is .. :math:`R` :math:`A \land R`
164+
NN is .. :math:`A` :math:`A \land \neg{R}`
165+
============ ======================= ===================
166+
167+
In practice, the theoretic types for strict type guards cannot be expressed
168+
precisely in the Python type system. Type checkers should fall back on
169+
practical approximations of these types. As a rule of thumb, a type checker
170+
should use the same type narrowing logic -- and get results that are consistent
171+
with -- its handling of "isinstance". This guidance allows for changes and
172+
improvements if the type system is extended in the future.
173+
174+
175+
Additional Examples
176+
===================
177+
178+
``Any`` is consistent [#isconsistent]_ with any other type, which means
179+
stricter semantics can be applied.
180+
181+
.. code-block:: python
182+
183+
# Stricter type guard semantics are used in this case because
184+
# "str" is consistent with "Any"
185+
def is_str(x: Any) -> TypeGuard[str]:
186+
return isinstance(x, str)
187+
188+
def test(x: float | str):
189+
if is_str(x):
190+
reveal_type(x) # str
191+
else:
192+
reveal_type(x) # float
193+
194+
195+
Backwards Compatibility
196+
=======================
197+
198+
This PEP proposes to change the existing behavior of ``TypeGuard``. This has no
199+
effect at runtime, but it does change the types evaluated by a type checker.
200+
201+
.. code-block:: python
202+
203+
def is_int(val: int | str) -> TypeGuard[int]:
204+
return isinstance(val, int)
205+
206+
def func(val: int | str):
207+
if is_int(val):
208+
reveal_type(val) # "int"
209+
else:
210+
reveal_type(val) # Previously "int | str", now "str"
211+
212+
213+
This behavioral change results in different types evaluated by a type checker.
214+
It could therefore produce new (or mask existing) type errors.
215+
216+
Type checkers often improve narrowing logic or fix existing bugs in such logic,
217+
so users of static typing will be used to this type of behavioral change.
218+
219+
We also hypothesize that it is unlikely that existing typed Python code relies
220+
on the current behavior of ``TypeGuard``. To validate our hypothesis, we
221+
implemented the proposed change in pyright and ran this modified version on
222+
roughly 25 typed code bases using `mypy primer`__ to see if there were any
223+
differences in the output. As predicted, the behavioral change had minimal
224+
impact. The only noteworthy change was that some ``# type: ignore`` comments
225+
were no longer necessary, indicating that these code bases were already working
226+
around the existing limitations of ``TypeGuard``.
227+
228+
__ https://github.com/hauntsaninja/mypy_primer
229+
230+
Breaking change
231+
---------------
232+
233+
It is possible for a user-defined type guard function to rely on the old
234+
behavior. Such type guard functions could break with the new behavior.
235+
236+
.. code-block:: python
237+
238+
def is_positive_int(val: int | str) -> TypeGuard[int]:
239+
return isinstance(val, int) and val > 0
240+
241+
def func(val: int | str):
242+
if is_positive_int(val):
243+
reveal_type(val) # "int"
244+
else:
245+
# With the older behavior, the type of "val" is evaluated as
246+
# "int | str"; with the new behavior, the type is narrowed to
247+
# "str", which is perhaps not what was intended.
248+
reveal_type(val)
249+
250+
We think it is unlikley that such user-defined type guards exist in real-world
251+
code. The mypy primer results didn't uncover any such cases.
252+
253+
254+
How to Teach This
255+
=================
256+
257+
Users unfamiliar with ``TypeGuard`` are likely to expect the behavior outlined
258+
in this PEP, therefore making ``TypeGuard`` easier to teach and explain.
259+
260+
261+
Reference Implementation
262+
========================
263+
264+
A reference `implementation`__ of this idea exists in pyright.
265+
266+
__ https://github.com/microsoft/pyright/commit/9a5af798d726bd0612cebee7223676c39cf0b9b0
267+
268+
To enable the modified behavior, the configuration flag
269+
``enableExperimentalFeatures`` must be set to true. This can be done on a
270+
per-file basis by adding a comment:
271+
272+
.. code-block:: python
273+
274+
# pyright: enableExperimentalFeatures=true
275+
276+
277+
Rejected Ideas
278+
==============
279+
280+
StrictTypeGuard
281+
---------------
282+
283+
A new ``StrictTypeGuard`` construct was proposed. This alternative form would
284+
be similar to a ``TypeGuard`` except it would apply stricter type guard
285+
semantics. It would also enforce that the return type was consistent
286+
[#isconsistent]_ with the input type. See this thread for details:
287+
`StrictTypeGuard proposal`__
288+
289+
__ https://github.com/python/typing/discussions/1013#discussioncomment-1966238
290+
291+
This idea was rejected because it is unnecessary in most cases and added
292+
unnecessary complexity. It would require the introduction of a new special
293+
form, and developers would need to be educated about the subtle difference
294+
between the two forms.
295+
296+
TypeGuard with a second output type
297+
-----------------------------------
298+
299+
Another idea was proposed where ``TypeGuard`` could support a second optional
300+
type argument that indicates the type that should be used for narrowing in the
301+
negative ("else") case.
302+
303+
.. code-block:: python
304+
305+
def is_int(val: int | str) -> TypeGuard[int, str]:
306+
return isinstance(val, int)
307+
308+
309+
This idea was proposed `here`__.
310+
311+
__ https://github.com/python/typing/issues/996
312+
313+
It was rejected because it was considered too complicated and addressed only
314+
one of the two main limitations of ``TypeGuard``. Refer to this `thread`__ for
315+
the full discussion.
316+
317+
__ https://mail.python.org/archives/list/[email protected]/thread/EMUD2D424OI53DCWQ4H5L6SJD2IXBHUL
318+
319+
320+
Footnotes
321+
=========
322+
323+
.. [#isconsistent] :pep:`PEP 483's discussion of is-consistent <483#summary-of-gradual-typing>`
324+
325+
326+
Copyright
327+
=========
328+
329+
This document is placed in the public domain or under the
330+
CC0-1.0-Universal license, whichever is more permissive.
331+

0 commit comments

Comments
 (0)