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