|
| 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