Skip to content

Commit 28fa515

Browse files
Add guide to type narrowing (#1798)
Some of the text derives from the "How to teach this" section of PEP 742. The asyncio example is based on a sample written by Liz King in https://discuss.python.org/t/problems-with-typeis/55410. Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
1 parent dc105fe commit 28fa515

File tree

2 files changed

+343
-0
lines changed

2 files changed

+343
-0
lines changed

docs/guides/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ Type System Guides
1010
writing_stubs
1111
modernizing
1212
unreachable
13+
type_narrowing
1314
typing_anti_pitch

docs/guides/type_narrowing.rst

+342
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
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

Comments
 (0)