Skip to content

Commit 53c3d1a

Browse files
PEP 742: TypeNarrower (python#3649)
Co-authored-by: Hugo van Kemenade <[email protected]>
1 parent 708a729 commit 53c3d1a

File tree

3 files changed

+338
-0
lines changed

3 files changed

+338
-0
lines changed

.github/CODEOWNERS

+1
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@ peps/pep-0736.rst @gvanrossum @Rosuav
619619
peps/pep-0737.rst @vstinner
620620
peps/pep-0738.rst @encukou
621621
peps/pep-0740.rst @dstufft
622+
peps/pep-0742.rst @JelleZijlstra
622623
# ...
623624
# peps/pep-0754.rst
624625
# ...

peps/pep-0483.rst

+1
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ structural subtyping is considered more flexible.
165165
We strive to provide support for both approaches, so that
166166
structural information can be used in addition to nominal subtyping.
167167

168+
.. _pep-483-gradual-typing:
168169

169170
Summary of gradual typing
170171
=========================

peps/pep-0742.rst

+336
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
PEP: 742
2+
Title: Narrowing types with TypeNarrower
3+
Author: Jelle Zijlstra <[email protected]>
4+
Status: Draft
5+
Type: Standards Track
6+
Topic: Typing
7+
Created: 07-Feb-2024
8+
Python-Version: 3.13
9+
Replaces: 724
10+
11+
12+
Abstract
13+
========
14+
15+
This PEP proposes a new special form, ``TypeNarrower``, to allow annotating functions that can be used
16+
to narrow the type of a value, similar to the builtin :py:func:`isinstance`. Unlike the existing
17+
:py:data:`typing.TypeGuard` special form, ``TypeNarrower`` can narrow the type in both the ``if``
18+
and ``else`` branches of a conditional.
19+
20+
21+
Motivation
22+
==========
23+
24+
Typed Python code often requires users to narrow the type of a variable based on a conditional.
25+
For example, if a function accepts a union of two types, it may use an :py:func:`isinstance` check
26+
to discriminate between the two types. Type checkers commonly support type narrowing based on various
27+
builtin function and operations, but occasionally, it is useful to use a user-defined function to
28+
perform type narrowing.
29+
30+
To support such use cases, :pep:`647` introduced the :py:data:`typing.TypeGuard` special form, which
31+
allows users to define type guards::
32+
33+
from typing import assert_type, TypeGuard
34+
35+
def is_str(x: object) -> TypeGuard[str]:
36+
return isinstance(x, str)
37+
38+
def f(x: object) -> None:
39+
if is_str(x):
40+
assert_type(x, str)
41+
else:
42+
assert_type(x, object)
43+
44+
Unfortunately, the behavior of :py:data:`typing.TypeGuard` has some limitations that make it
45+
less useful for many common use cases, as explained also in the "Motivation" section of :pep:`724`.
46+
In particular:
47+
48+
* Type checkers must use exactly the ``TypeGuard`` return type as the narrowed type if the
49+
type guard returns ``True``. They cannot use pre-existing knowledge about the type of the
50+
variable.
51+
* In the case where the type guard returns ``False``, the type checker cannot apply any
52+
additional narrowing.
53+
54+
The standard library function :py:func:`inspect.isawaitable` may serve as an example. It
55+
returns whether the argument is an awaitable object, and
56+
`typeshed <https://github.com/python/typeshed/blob/a4f81a67a07c18dd184dd068c459b02e71bcac22/stdlib/inspect.pyi#L219>`__
57+
currently annotates it as::
58+
59+
def isawaitable(object: object) -> TypeGuard[Awaitable[Any]]: ...
60+
61+
A user `reported <https://github.com/python/mypy/issues/15520>`__ an issue to mypy about
62+
the behavior of this function. They observed the following behavior::
63+
64+
import inspect
65+
from collections.abc import Awaitable
66+
from typing import reveal_type
67+
68+
async def f(t: Awaitable[int] | int) -> None:
69+
if inspect.isawaitable(t):
70+
reveal_type(t) # Awaitable[Any]
71+
else:
72+
reveal_type(t) # Awaitable[int] | int
73+
74+
This behavior is consistent with :pep:`647`, but it did not match the user's expectations.
75+
Instead, they would expect the type of ``t`` to be narrowed to ``Awaitable[int]`` in the ``if``
76+
branch, and to ``int`` in the ``else`` branch. This PEP proposes a new construct that does
77+
exactly that.
78+
79+
Other examples of issues that arose out of the current behavior of ``TypeGuard`` include:
80+
81+
* `Python typing issue <https://github.com/python/typing/issues/996>`__ (``numpy.isscalar``)
82+
* `Python typing issue <https://github.com/python/typing/issues/1351>`__ (:py:func:`dataclasses.is_dataclass`)
83+
* `Pyright issue <https://github.com/microsoft/pyright/issues/3450>`__ (expecting :py:data:`typing.TypeGuard` to work like :py:func:`isinstance`)
84+
* `Pyright issue <https://github.com/microsoft/pyright/issues/3466>`__ (expecting narrowing in the ``else`` branch)
85+
* `Mypy issue <https://github.com/python/mypy/issues/13957>`__ (expecting narrowing in the ``else`` branch)
86+
* `Mypy issue <https://github.com/python/mypy/issues/14434>`__ (combining multiple TypeGuards)
87+
* `Mypy issue <https://github.com/python/mypy/issues/15305>`__ (expecting narrowing in the ``else`` branch)
88+
* `Mypy issue <https://github.com/python/mypy/issues/11907>`__ (user-defined function similar to :py:func:`inspect.isawaitable`)
89+
* `Typeshed issue <https://github.com/python/typeshed/issues/8009>`__ (``asyncio.iscoroutinefunction``)
90+
91+
Rationale
92+
=========
93+
94+
The problems with the current behavior of :py:data:`typing.TypeGuard` compel us to improve
95+
the type system to allow a different type narrowing behavior. :pep:`724` proposed to change
96+
the behavior of the existing :py:data:`typing.TypeGuard` construct, but we :ref:`believe <pep-742-change-typeguard>`
97+
that the backwards compatibility implications of that change are too severe. Instead, we propose
98+
adding a new special form with the desired semantics.
99+
100+
We acknowledge that this leads to an unfortunate situation where there are two constructs with
101+
a similar purpose and similar semantics. We believe that users are more likely to want the behavior
102+
of ``TypeNarrower``, the new form proposed in this PEP, and therefore we recommend that documentation
103+
emphasize ``TypeNarrower`` over ``TypeGuard`` as a more commonly applicable tool. However, the semantics of
104+
``TypeGuard`` are occasionally useful, and we do not propose to deprecate or remove it. In the long
105+
run, most users should use ``TypeNarrower``, and ``TypeGuard`` should be reserved for rare cases
106+
where its behavior is specifically desired.
107+
108+
109+
Specification
110+
=============
111+
112+
A new special form, ``TypeNarrower``, is added to the :py:mod:`typing`
113+
module. Its usage, behavior, and runtime implementation are similar to
114+
those of :py:data:`typing.TypeGuard`.
115+
116+
It accepts a single
117+
argument and can be used as the return type of a function. A function annotated as returning a
118+
``TypeNarrower`` is called a type narrowing function. Type narrowing functions must return ``bool``
119+
values, and the type checker should verify that all return paths return
120+
``bool``.
121+
122+
Type narrowing functions must accept at least one positional argument. The type
123+
narrowing behavior is applied to the first positional argument passed to
124+
the function. The function may accept additional arguments, but they are
125+
not affected by type narrowing. If a type narrowing function is implemented as
126+
an instance method or class method, the first positional argument maps
127+
to the second parameter (after ``self`` or ``cls``).
128+
129+
Type narrowing behavior
130+
-----------------------
131+
132+
To specify the behavior of ``TypeNarrower``, we use the following terminology:
133+
134+
* I = ``TypeNarrower`` input type
135+
* R = ``TypeNarrower`` return type
136+
* A = Type of argument passed to type narrowing function (pre-narrowed)
137+
* NP = Narrowed type (positive; used when ``TypeNarrower`` returned ``True``)
138+
* NN = Narrowed type (negative; used when ``TypeNarrower`` returned ``False``)
139+
140+
.. code-block:: python
141+
142+
def narrower(x: I) -> TypeNarrower[R]: ...
143+
144+
def func1(val: A):
145+
if narrower(val):
146+
assert_type(val, NP)
147+
else:
148+
assert_type(val, NN)
149+
150+
The return type ``R`` must be :ref:`consistent with <pep-483-gradual-typing>` ``I``. The type checker should
151+
emit an error if this condition is not met.
152+
153+
Formally, type *NP* should be narrowed to :math:`A \land R`,
154+
the intersection of *A* and *R*, and type *NN* should be narrowed to
155+
:math:`A \land \neg R`, the intersection of *A* and the complement of *R*.
156+
In practice, the theoretic types for strict type guards cannot be expressed
157+
precisely in the Python type system. Type checkers should fall back on
158+
practical approximations of these types. As a rule of thumb, a type checker
159+
should use the same type narrowing logic -- and get results that are consistent
160+
with -- its handling of :py:func:`isinstance`. This guidance allows for changes and
161+
improvements if the type system is extended in the future.
162+
163+
Examples
164+
--------
165+
166+
Type narrowing is applied in both the positive and negative case::
167+
168+
from typing import TypeNarrower, assert_type
169+
170+
def is_str(x: object) -> TypeNarrower[str]:
171+
return isinstance(x, str)
172+
173+
def f(x: str | int) -> None:
174+
if is_str(x):
175+
assert_type(x, str)
176+
else:
177+
assert_type(x, int)
178+
179+
The final narrowed type may be narrower than **R**, due to the constraints of the
180+
argument's previously-known type::
181+
182+
from collections.abc import Awaitable
183+
from typing import Any, TypeNarrower, assert_type
184+
import inspect
185+
186+
def isawaitable(x: object) -> TypeNarrower[Awaitable[Any]]:
187+
return inspect.isawaitable(x)
188+
189+
def f(x: Awaitable[int] | int) -> None:
190+
if isawaitable(x):
191+
assert_type(x, Awaitable[int])
192+
else:
193+
assert_type(x, int)
194+
195+
It is an error to narrow to a type that is not consistent with the input type::
196+
197+
from typing import TypeNarrower
198+
199+
def is_str(x: int) -> TypeNarrower[str]: # Type checker error
200+
...
201+
202+
Subtyping
203+
---------
204+
205+
``TypeNarrower`` is not a subtype of ``bool``.
206+
The type ``Callable[..., TypeNarrower[int]]`` is not assignable to
207+
``Callable[..., bool]`` or ``Callable[..., TypeGuard[int]]``, and vice versa.
208+
This restriction is carried over from :pep:`647`. It may be possible to relax
209+
it in the future, but that is outside the scope of this PEP.
210+
211+
Unlike ``TypeGuard``, ``TypeNarrower`` is invariant in its argument type:
212+
``TypeNarrower[B]`` is not a subtype of ``TypeNarrower[A]``,
213+
even if ``B`` is a subtype of ``A``.
214+
To see why, consider the following example::
215+
216+
def takes_narrower(x: int | str, narrower: Callable[[object], TypeNarrower[int]]):
217+
if narrower(x):
218+
print(x + 1) # x is an int
219+
else:
220+
print("Hello " + x) # x is a str
221+
222+
def is_bool(x: object) -> TypeNarrower[bool]:
223+
return isinstance(x, bool)
224+
225+
takes_narrower(1, is_bool) # Error: is_bool is not a TypeNarrower[int]
226+
227+
(Note that ``bool`` is a subtype of ``int``.)
228+
This code fails at runtime, because the narrower returns ``False`` (1 is not a ``bool``)
229+
and the ``else`` branch is taken in ``takes_narrower()``.
230+
If the call ``takes_narrower(1, is_bool)`` was allowed, type checkers would fail to
231+
detect this error.
232+
233+
Backwards Compatibility
234+
=======================
235+
236+
As this PEP only proposes a new special form, there are no implications on
237+
backwards compatibility.
238+
239+
240+
Security Implications
241+
=====================
242+
243+
None known.
244+
245+
246+
How to Teach This
247+
=================
248+
249+
Introductions to typing should cover ``TypeNarrower`` when discussing how to narrow types,
250+
along with discussion of other narrowing constructs such as :py:func:`isinstance`. The
251+
documentation should emphasize ``TypeNarrower`` over :py:data:`typing.TypeGuard`; while the
252+
latter is not being deprecated and its behavior is occasionally useful, we expect that the
253+
behavior of ``TypeNarrower`` is usually more intuitive, and most users should reach for
254+
``TypeNarrower`` first.
255+
256+
257+
Reference Implementation
258+
========================
259+
260+
A draft implementation for mypy `is available <https://github.com/python/mypy/pull/16898>`__.
261+
262+
263+
Rejected Ideas
264+
==============
265+
266+
.. _pep-742-change-typeguard:
267+
268+
Change the behavior of ``TypeGuard``
269+
------------------------------------
270+
271+
:pep:`724` previously proposed changing the specified behavior of :py:data:`typing.TypeGuard` so
272+
that if the return type of the guard is consistent with the input type, the behavior proposed
273+
here for ``TypeNarrower`` would apply. This proposal has some important advantages: because it
274+
does not require any runtime changes, it requires changes only in type checkers, making it easier
275+
for users to take advantage of the new, usually more intuitive behavior.
276+
277+
However, this approach has some major problems. Users who have written ``TypeGuard`` functions
278+
expecting the existing semantics specified in :pep:`647` would see subtle and potentially breaking
279+
changes in how type checkers interpret their code. The split behavior of ``TypeGuard``, where it
280+
works one way if the return type is consistent with the input type and another way if it is not,
281+
could be confusing for users. The Typing Council was unable to come to an agreement in favor of
282+
:pep:`724`; as a result, we are proposing this alternative PEP.
283+
284+
Do nothing
285+
----------
286+
287+
Both this PEP and the alternative proposed in :pep:`724` have shortcomings. The latter are
288+
discussed above. As for this PEP, it introduces two special forms with very similar semantics,
289+
and it potentially creates a long migration path for users currently using ``TypeGuard``
290+
who would be better off with different narrowing semantics.
291+
292+
One way forward, then, is to do nothing and live with the current limitations of the type system.
293+
However, we believe that the limitations of the current ``TypeGuard``, as outlined in the "Motivation"
294+
section, are significant enough that it is worthwhile to change the type system to address them.
295+
If we do not make any change, users will continue to encounter the same unintuitive behaviors from
296+
``TypeGuard``, and the type system will be unable to properly represent common type narrowing functions
297+
like ``inspect.isawaitable``.
298+
299+
Open Issues
300+
===========
301+
302+
Naming
303+
------
304+
305+
This PEP currently proposes the name ``TypeNarrower``, emphasizing that the special form narrows
306+
the type of its argument. However, other names have been suggested, and we are open to using a
307+
different name.
308+
309+
Options include:
310+
311+
* ``IsInstance`` (`post by Paul Moore <https://discuss.python.org/t/pep-724-stricter-type-guards/34124/60>`__):
312+
emphasizes that the new construct behaves similarly to the builtin :py:func:`isinstance`.
313+
* ``Narrowed`` or ``NarrowedTo``: shorter than ``TypeNarrower`` but keeps the connection to "type narrowing"
314+
(suggested by Eric Traut).
315+
* ``Predicate`` or ``TypePredicate``: mirrors TypeScript's name for the feature, "type predicates".
316+
* ``StrictTypeGuard`` (earlier drafts of :pep:`724`): emphasizes that the new construct performs a stricter
317+
version of type narrowing than :py:data:`typing.TypeGuard`.
318+
* ``TypeCheck`` (`post by Nicolas Tessore <https://discuss.python.org/t/pep-724-stricter-type-guards/34124/59>`__):
319+
emphasizes the binary nature of the check.
320+
* ``TypeIs``: emphasizes that the function returns whether the argument is of that type; mirrors
321+
`TypeScript's syntax <https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates>`__.
322+
323+
Acknowledgments
324+
===============
325+
326+
Much of the motivation and specification for this PEP derives from :pep:`724`. While
327+
this PEP proposes a different solution for the problem at hand, the authors of :pep:`724`, Eric Traut, Rich
328+
Chiodo, and Erik De Bonte, made a strong case for their proposal and this proposal
329+
would not have been possible without their work.
330+
331+
332+
Copyright
333+
=========
334+
335+
This document is placed in the public domain or under the
336+
CC0-1.0-Universal license, whichever is more permissive.

0 commit comments

Comments
 (0)