|
| 1 | +************** |
| 2 | +Type Narrowing |
| 3 | +************** |
| 4 | + |
| 5 | +Python programs often contain symbols that take on multiple types within a |
| 6 | +single given scope and that are distinguished by a conditional check at |
| 7 | +runtime. For example, here the variable *name* can be either a ``str`` or |
| 8 | +``None``, and the ``if name is not None`` narrows it down to just ``str``:: |
| 9 | + |
| 10 | + def maybe_greet(name: str | None) -> None: |
| 11 | + if name is not None: |
| 12 | + print("Hello, " + name) |
| 13 | + |
| 14 | +This technique is called *type narrowing*. |
| 15 | +To avoid false positives on such code, type checkers understand |
| 16 | +various kinds of conditional checks that are used to narrow types in Python code. |
| 17 | +The exact set of type narrowing constructs that a type checker understands |
| 18 | +is not specified and varies across type checkers. Commonly understood |
| 19 | +patterns include: |
| 20 | + |
| 21 | +* ``if x is not None`` |
| 22 | +* ``if x`` |
| 23 | +* ``if isinstance(x, SomeType)`` |
| 24 | +* ``if callable(x)`` |
| 25 | + |
| 26 | +In addition to narrowing local variables, type checkers usually also support |
| 27 | +narrowing instance attributes and sequence members, such as |
| 28 | +``if x.some_attribute is not None`` or ``if x[0] is not None``, though the exact |
| 29 | +conditions for this behavior differ between type checkers. |
| 30 | + |
| 31 | +Consult your type checker's documentation for more information on the type |
| 32 | +narrowing constructs it supports. |
| 33 | + |
| 34 | +The type system also includes two ways to create *user-defined* type narrowing |
| 35 | +functions: :py:data:`typing.TypeIs` and :py:data:`typing.TypeGuard`. These |
| 36 | +are useful if you want to reuse a more complicated check in multiple places, or |
| 37 | +you use a check that the type checker doesn't understand. In these cases, you |
| 38 | +can define a ``TypeIs`` or ``TypeGuard`` function to perform the check and allow type checkers |
| 39 | +to use it to narrow the type of a variable. Between the two, ``TypeIs`` usually |
| 40 | +has the more intuitive behavior, so we'll talk about it more; see |
| 41 | +:ref:`below <guide-type-narrowing-typeis-typeguard>` for a comparison. |
| 42 | + |
| 43 | +How to use ``TypeIs`` and ``TypeGuard`` |
| 44 | +--------------------------------------- |
| 45 | + |
| 46 | +A ``TypeIs`` function takes a single argument and is annotated as returning |
| 47 | +``TypeIs[T]``, where ``T`` is the type that you want to narrow to. The function |
| 48 | +must return ``True`` if the argument is of type ``T``, and ``False`` otherwise. |
| 49 | +The function can then be used in ``if`` checks, just like you would use ``isinstance()``. |
| 50 | +For example:: |
| 51 | + |
| 52 | + from typing import Literal, TypeIs |
| 53 | + |
| 54 | + type Direction = Literal["N", "E", "S", "W"] |
| 55 | + |
| 56 | + def is_direction(x: str) -> TypeIs[Direction]: |
| 57 | + return x in {"N", "E", "S", "W"} |
| 58 | + |
| 59 | + def maybe_direction(x: str) -> None: |
| 60 | + if is_direction(x): |
| 61 | + print(f"{x} is a cardinal direction") |
| 62 | + else: |
| 63 | + print(f"{x} is not a cardinal direction") |
| 64 | + |
| 65 | +A ``TypeGuard`` function looks similar and is used in the same way, but the |
| 66 | +type narrowing behavior is different, as dicussed in :ref:`the section below <guide-type-narrowing-typeis-typeguard>`. |
| 67 | + |
| 68 | +Depending on the version of Python you are running, you will be able to |
| 69 | +import ``TypeIs`` and ``TypeGuard`` either from the standard library :py:mod:`typing` |
| 70 | +module or from the third-party ``typing_extensions`` module: |
| 71 | + |
| 72 | +* ``TypeIs`` is in ``typing`` starting from Python 3.13 and in ``typing_extensions`` |
| 73 | + starting from version 4.10.0. |
| 74 | +* ``TypeGuard`` is in ``typing`` starting from Python 3.10 and in ``typing_extensions`` |
| 75 | + starting from version 3.10.0.0. |
| 76 | + |
| 77 | + |
| 78 | +Writing a correct ``TypeIs`` function |
| 79 | +------------------------------------- |
| 80 | + |
| 81 | +A ``TypeIs`` function allows you to override your type checker's type narrowing |
| 82 | +behavior. This is a powerful tool, but it can be dangerous because an incorrectly |
| 83 | +written ``TypeIs`` function can lead to unsound type checking, and type checkers |
| 84 | +cannot detect such errors. |
| 85 | + |
| 86 | +For a function returning ``TypeIs[T]`` to be correct, it must return ``True`` if and only if |
| 87 | +the argument is of type ``T``, and ``False`` otherwise. If this condition is |
| 88 | +not met, the type checker may infer incorrect types. |
| 89 | + |
| 90 | +Below are some examples of correct and incorrect ``TypeIs`` functions:: |
| 91 | + |
| 92 | + from typing import TypeIs |
| 93 | + |
| 94 | + # Correct |
| 95 | + def is_int(x: object) -> TypeIs[int]: |
| 96 | + return isinstance(x, int) |
| 97 | + |
| 98 | + # Incorrect: does not return True for all ints |
| 99 | + def is_positive_int(x: object) -> TypeIs[int]: |
| 100 | + return isinstance(x, int) and x > 0 |
| 101 | + |
| 102 | + # Incorrect: returns True for some non-ints |
| 103 | + def is_real_number(x: object) -> TypeIs[int]: |
| 104 | + return isinstance(x, (int, float)) |
| 105 | + |
| 106 | +This function demonstrates some errors that can occur when using a poorly written |
| 107 | +``TypeIs`` function. These errors are not detected by type checkers:: |
| 108 | + |
| 109 | + def caller(x: int | str, y: int | float) -> None: |
| 110 | + if is_positive_int(x): # narrowed to int |
| 111 | + print(x + 1) |
| 112 | + else: # narrowed to str (incorrectly) |
| 113 | + print("Hello " + x) # runtime error if x is a negative int |
| 114 | + |
| 115 | + if is_real_number(y): # narrowed to int |
| 116 | + # Because of the incorrect TypeIs, this branch is taken at runtime if |
| 117 | + # y is a float. |
| 118 | + print(y.bit_count()) # runtime error: this method exists only on int, not float |
| 119 | + else: # narrowed to float (though never executed at runtime) |
| 120 | + pass |
| 121 | + |
| 122 | +Here is an example of a correct ``TypeIs`` function for a more complicated type:: |
| 123 | + |
| 124 | + from typing import TypedDict, TypeIs |
| 125 | + |
| 126 | + class Point(TypedDict): |
| 127 | + x: int |
| 128 | + y: int |
| 129 | + |
| 130 | + def is_point(obj: object) -> TypeIs[Point]: |
| 131 | + return ( |
| 132 | + isinstance(obj, dict) |
| 133 | + and all(isinstance(key, str) for key in obj) |
| 134 | + and isinstance(obj.get("x"), int) |
| 135 | + and isinstance(obj.get("y"), int) |
| 136 | + ) |
| 137 | + |
| 138 | +.. _`guide-type-narrowing-typeis-typeguard`: |
| 139 | + |
| 140 | +``TypeIs`` and ``TypeGuard`` |
| 141 | +---------------------------- |
| 142 | + |
| 143 | +:py:data:`typing.TypeIs` and :py:data:`typing.TypeGuard` are both tools for narrowing the type of a variable |
| 144 | +based on a user-defined function. Both can be used to annotate functions that take an |
| 145 | +argument and return a boolean depending on whether the input argument is compatible with |
| 146 | +the narrowed type. These function can then be used in ``if`` checks to narrow the type |
| 147 | +of a variable. |
| 148 | + |
| 149 | +``TypeIs`` usually has the more intuitive behavior, but it |
| 150 | +introduces more restrictions. ``TypeGuard`` is the right tool to use if: |
| 151 | + |
| 152 | +* You want to narrow to a type that is not :term:`assignable` to the input type, for example |
| 153 | + from ``list[object]`` to ``list[int]``. ``TypeIs`` only allows narrowing between |
| 154 | + compatible types. |
| 155 | +* Your function does not return ``True`` for all input values that are members of |
| 156 | + the narrowed type. For example, you could have a ``TypeGuard[int]`` that returns ``True`` |
| 157 | + only for positive integers. |
| 158 | + |
| 159 | +``TypeIs`` and ``TypeGuard`` differ in the following ways: |
| 160 | + |
| 161 | +* ``TypeIs`` requires the narrowed type to be :term:`assignable` to the input type, while |
| 162 | + ``TypeGuard`` does not. |
| 163 | +* When a ``TypeGuard`` function returns ``True``, type checkers narrow the type of the |
| 164 | + variable to exactly the ``TypeGuard`` type. When a ``TypeIs`` function returns ``True``, |
| 165 | + type checkers can infer a more precise type combining the previously known type of the |
| 166 | + variable with the ``TypeIs`` type. (This is known as an "intersection type".) |
| 167 | +* When a ``TypeGuard`` function returns ``False``, type checkers cannot narrow the type of |
| 168 | + the variable at all. When a ``TypeIs`` function returns ``False``, type checkers can narrow |
| 169 | + the type of the variable to exclude the ``TypeIs`` type. |
| 170 | + |
| 171 | +This behavior can be seen in the following example:: |
| 172 | + |
| 173 | + from typing import TypeGuard, TypeIs, reveal_type, final |
| 174 | + |
| 175 | + class Base: ... |
| 176 | + class Child(Base): ... |
| 177 | + @final |
| 178 | + class Unrelated: ... |
| 179 | + |
| 180 | + def is_base_typeguard(x: object) -> TypeGuard[Base]: |
| 181 | + return isinstance(x, Base) |
| 182 | + |
| 183 | + def is_base_typeis(x: object) -> TypeIs[Base]: |
| 184 | + return isinstance(x, Base) |
| 185 | + |
| 186 | + def use_typeguard(x: Child | Unrelated) -> None: |
| 187 | + if is_base_typeguard(x): |
| 188 | + reveal_type(x) # Base |
| 189 | + else: |
| 190 | + reveal_type(x) # Child | Unrelated |
| 191 | + |
| 192 | + def use_typeis(x: Child | Unrelated) -> None: |
| 193 | + if is_base_typeis(x): |
| 194 | + reveal_type(x) # Child |
| 195 | + else: |
| 196 | + reveal_type(x) # Unrelated |
| 197 | + |
| 198 | + |
| 199 | +Safety and soundness |
| 200 | +-------------------- |
| 201 | + |
| 202 | +While type narrowing is important for typing real-world Python code, many |
| 203 | +forms of type narrowing are unsafe in the presence of mutability. Type checkers |
| 204 | +attempt to limit type narrowing in a way that minimizes unsafety while remaining |
| 205 | +useful, but not all safety violations can be detected. |
| 206 | + |
| 207 | +``isinstance()`` and ``issubclass()`` |
| 208 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 209 | + |
| 210 | +While the exact behavior is not standardized, type checkers usually support |
| 211 | +narrowing terms based on calls to ``isinstance()`` and ``issubclass()``. However, |
| 212 | +these functions have complex runtime behavior that type checkers cannot fully |
| 213 | +capture: they call the :py:meth:`__instancecheck__` and :py:meth:`__subclasscheck__` |
| 214 | +special methods, which may include arbitrarily complex logic. |
| 215 | + |
| 216 | +This affects some parts of the standard library that rely on these methods. |
| 217 | +:py:class:`abc.ABC` allows registration of subclasses using the ``.register()`` method, |
| 218 | +but type checkers usually will not recognize this method. :ref:`Runtime-checkable |
| 219 | +protocols <runtime-checkable>` support runtime ``isinstance()`` checks, but their |
| 220 | +behavior does not exactly match the type system (for example, the types of method |
| 221 | +parameters are not checked). |
| 222 | + |
| 223 | +Incorrect ``TypeIs`` and ``TypeGuard`` functions |
| 224 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 225 | + |
| 226 | +Both ``TypeIs`` and ``TypeGuard`` rely on the user writing a function that |
| 227 | +returns whether an object is of a particular type. However, the type checker |
| 228 | +does not validate whether the function actually behaves as expected. If it |
| 229 | +does not, the type checker's narrowing behavior will not match what happens |
| 230 | +at runtime.:: |
| 231 | + |
| 232 | + from typing import TypeIs |
| 233 | + |
| 234 | + def is_str(x: object) -> TypeIs[str]: |
| 235 | + return True |
| 236 | + |
| 237 | + def takes_str_or_int(x: str | int) -> None: |
| 238 | + if is_str(x): |
| 239 | + print(x + " is a string") # runtime error |
| 240 | + |
| 241 | +To avoid this problem, every ``TypeIs`` and ``TypeGuard`` function should be |
| 242 | +carefully reviewed and tested. |
| 243 | + |
| 244 | +Unsound ``TypeGuard`` narrowing |
| 245 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 246 | + |
| 247 | +Unlike ``TypeIs``, ``TypeGuard`` can narrow to a type that is not a subtype of the |
| 248 | +original type. This allows for unsafe behavior with invariant data structures:: |
| 249 | + |
| 250 | + from typing import Any, TypeGuard |
| 251 | + |
| 252 | + def is_int_list(x: list[Any]) -> TypeGuard[list[int]]: |
| 253 | + return all(isinstance(i, int) for i in x) |
| 254 | + |
| 255 | + def maybe_mutate_list(x: list[Any]) -> None: |
| 256 | + if is_int_list(x): |
| 257 | + x.append(0) # OK, x is narrowed to list[int] |
| 258 | + |
| 259 | + def takes_bool_list(x: list[bool]) -> None: |
| 260 | + maybe_mutate_list(x) |
| 261 | + reveal_type(x) # list[bool] |
| 262 | + assert all(isinstance(i, bool) for i in x) # fails at runtime |
| 263 | + |
| 264 | + takes_bool_list([True, False]) |
| 265 | + |
| 266 | +To avoid this problem, use ``TypeIs`` instead of ``TypeGuard`` where possible. |
| 267 | +If you must use ``TypeGuard``, avoid narrowing across incompatible types. |
| 268 | +Prefer using covariant, immutable types in parameter annotations (e.g., |
| 269 | +``Sequence`` or ``Iterable`` instead of ``list``). If you do this, it is more likely |
| 270 | +that you'll be able to use ``TypeIs`` to implement your type narrowing functions. |
| 271 | + |
| 272 | +Invalidated assumptions |
| 273 | +~~~~~~~~~~~~~~~~~~~~~~~ |
| 274 | + |
| 275 | +One category of safety issues relates to the fact that type narrowing relies |
| 276 | +on a condition that was established at one point in the code and is then relied |
| 277 | +on later: we first check ``if x is not None``, then rely on ``x`` not being ``None``. |
| 278 | +However, in the meantime other code may have run (for example, in another thread, |
| 279 | +another coroutine, or simply some code that was invoked by a function call) and |
| 280 | +invalidated the earlier condition. |
| 281 | + |
| 282 | +Such problems are most likely when narrowing is performed on elements of mutable |
| 283 | +objects, but it is possible to construct unsafe examples even using only narrowing |
| 284 | +of local variables:: |
| 285 | + |
| 286 | + def maybe_greet(name: str | None) -> None: |
| 287 | + def set_it_to_none(): |
| 288 | + nonlocal name |
| 289 | + name = None |
| 290 | + |
| 291 | + if name is not None: |
| 292 | + set_it_to_none() |
| 293 | + # fails at runtime, no error in current type checkers |
| 294 | + print("Hello " + name) |
| 295 | + |
| 296 | + maybe_greet("Guido") |
| 297 | + |
| 298 | +A more realistic example might involve multiple coroutines mutating a list:: |
| 299 | + |
| 300 | + import asyncio |
| 301 | + from typing import Sequence, TypeIs |
| 302 | + |
| 303 | + def is_int_sequence(x: Sequence[object]) -> TypeIs[Sequence[int]]: |
| 304 | + return all(isinstance(i, int) for i in x) |
| 305 | + |
| 306 | + async def takes_seq(x: Sequence[int | None]): |
| 307 | + if is_int_sequence(x): |
| 308 | + await asyncio.sleep(2) |
| 309 | + print("The total is", sum(x)) # fails at runtime |
| 310 | + |
| 311 | + async def takes_list(x: list[int | None]): |
| 312 | + t = asyncio.create_task(takes_seq(x)) |
| 313 | + await asyncio.sleep(1) |
| 314 | + x.append(None) |
| 315 | + await t |
| 316 | + |
| 317 | + if __name__ == "__main__": |
| 318 | + lst: list[int | None] = [1, 2, 3] |
| 319 | + asyncio.run(takes_list(lst)) |
| 320 | + |
| 321 | +These issues unfortunately cannot be fully detected by the current |
| 322 | +Python type system. (An example of a different programming language that |
| 323 | +does solve this problem is Rust, which uses a system called |
| 324 | +`ownership <https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html>`__.) |
| 325 | +To avoid such issues, avoid using type narrowing on objects that are mutated |
| 326 | +from other parts of the code. |
| 327 | + |
| 328 | + |
| 329 | +See also |
| 330 | +-------- |
| 331 | + |
| 332 | +* Type checker documentation on type narrowing |
| 333 | + |
| 334 | + * `Mypy <https://mypy.readthedocs.io/en/stable/type_narrowing.html>`__ |
| 335 | + * `Pyright <https://microsoft.github.io/pyright/#/type-concepts-advanced?id=type-narrowing>`__ |
| 336 | + |
| 337 | +* PEPs related to type narrowing. These contain additional discussion |
| 338 | + and motivation for current type checker behaviors. |
| 339 | + |
| 340 | + * :pep:`647` (introduced ``TypeGuard``) |
| 341 | + * (*withdrawn*) :pep:`724` (proposed change to ``TypeGuard`` behavior) |
| 342 | + * :pep:`742` (introduced ``TypeIs``) |
0 commit comments