PEP 742: Expand "How to Teach This" (#3683)

Example sections on:
- When to use TypeIs
- How to write a safe TypeIs
- TypeIs vs. TypeGuard

Also note the implementations in typing-extensions, pyright, and pyanalyze.
This commit is contained in:
Jelle Zijlstra 2024-02-22 20:55:29 -08:00 committed by GitHub
parent 5995f0b7f9
commit c576162334
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 158 additions and 2 deletions

View File

@ -253,14 +253,170 @@ along with discussion of other narrowing constructs such as :py:func:`isinstance
documentation should emphasize ``TypeIs`` over :py:data:`typing.TypeGuard`; while the
latter is not being deprecated and its behavior is occasionally useful, we expect that the
behavior of ``TypeIs`` is usually more intuitive, and most users should reach for
``TypeIs`` first.
``TypeIs`` first. The rest of this section contains some example content that could
be used in introductory user-facing documentation.
When to use ``TypeIs``
----------------------
Python code often uses functions like ``isinstance()`` to distinguish between
different possible types of a value. Type checkers understand ``isinstance()``
and various other checks and use them to narrow the type of a variable. However,
sometimes you want to reuse a more complicated check in multiple places, or
you use a check that the type checker doesn't understand. In these cases, you
can define a ``TypeIs`` function to perform the check and allow type checkers
to use it to narrow the type of a variable.
A ``TypeIs`` function takes a single argument and is annotated as returning
``TypeIs[T]``, where ``T`` is the type that you want to narrow to. The function
must return ``True`` if the argument is of type ``T``, and ``False`` otherwise.
The function can then be used in ``if`` checks, just like you would use ``isinstance()``.
For example::
from typing import TypeIs, Literal
type Direction = Literal["N", "E", "S", "W"]
def is_direction(x: str) -> TypeIs[Direction]:
return x in {"N", "E", "S", "W"}
def maybe_direction(x: str) -> None:
if is_direction(x):
print(f"{x} is a cardinal direction")
else:
print(f"{x} is not a cardinal direction")
Writing a safe ``TypeIs`` function
----------------------------------
A ``TypeIs`` function allows you to override your type checker's type narrowing
behavior. This is a powerful tool, but it can be dangerous because an incorrectly
written ``TypeIs`` function can lead to unsound type checking, and type checkers
cannot detect such errors.
For a function returning ``TypeIs[T]`` to be safe, it must return ``True`` if and only if
the argument is compatible with type ``T``, and ``False`` otherwise. If this condition is
not met, the type checker may infer incorrect types.
Below are some examples of correct and incorrect ``TypeIs`` functions::
from typing import TypeIs
# Correct
def good_typeis(x: object) -> TypeIs[int]:
return isinstance(x, int)
# Incorrect: does not return True for all ints
def bad_typeis1(x: object) -> TypeIs[int]:
return isinstance(x, int) and x > 0
# Incorrect: returns True for some non-ints
def bad_typeis2(x: object) -> TypeIs[int]:
return isinstance(x, (int, float))
This function demonstrates some errors that can occur when using a poorly written
``TypeIs`` function. These errors are not detected by type checkers::
def caller(x: int | str, y: int | float) -> None:
if bad_typeis1(x): # narrowed to int
print(x + 1)
else: # narrowed to str (incorrectly)
print("Hello " + x) # runtime error if x is a negative int
if bad_typeis2(y): # narrowed to int
# Because of the incorrect TypeIs, this branch is taken at runtime if
# y is a float.
print(y.bit_count()) # runtime error: this method exists only on int, not float
else: # narrowed to float (though never executed at runtime)
pass
Here is an example of a correct ``TypeIs`` function for a more complicated type::
from typing import TypedDict, TypeIs
class Point(TypedDict):
x: int
y: int
def is_point(x: object) -> TypeIs[Point]:
return (
isinstance(x, dict)
and all(isinstance(key, str) for key in x)
and "x" in x
and "y" in x
and isinstance(x["x"], int)
and isinstance(x["y"], int)
)
``TypeIs`` and ``TypeGuard``
----------------------------
``TypeIs`` and :py:data:`typing.TypeGuard` are both tools for narrowing the type of a variable
based on a user-defined function. Both can be used to annotate functions that take an
argument and return a boolean depending on whether the input argument is compatible with
the narrowed type. These function can then be used in ``if`` checks to narrow the type
of a variable.
``TypeIs`` usually has the most intuitive behavior, but it
introduces more restrictions. ``TypeGuard`` is the right tool to use if:
* You want to narrow to a type that is not compatible with the input type, for example
from ``list[object]`` to ``list[int]``. ``TypeIs`` only allows narrowing between
compatible types.
* Your function does not return ``True`` for all input values that are compatible with
the narrowed type. For example, you could have a ``TypeGuard[int]`` that returns ``True``
only for positive integers.
``TypeIs`` and ``TypeGuard`` differ in the following ways:
* ``TypeIs`` requires the narrowed type to be a subtype of the input type, while
``TypeGuard`` does not.
* When a ``TypeGuard`` function returns ``True``, type checkers narrow the type of the
variable to exactly the ``TypeGuard`` type. When a ``TypeIs`` function returns ``True``,
type checkers can infer a more precise type combining the previously known type of the
variable with the ``TypeIs`` type. (Technically, this is known as an intersection type.)
* When a ``TypeGuard`` function returns ``False``, type checkers cannot narrow the type of
the variable at all. When a ``TypeIs`` function returns ``False``, type checkers can narrow
the type of the variable to exclude the ``TypeIs`` type.
This behavior can be seen in the following example::
from typing import TypeGuard, TypeIs, reveal_type, final
class Base: ...
class Child(Base): ...
@final
class Unrelated: ...
def is_base_typeguard(x: object) -> TypeGuard[Base]:
return isinstance(x, Base)
def is_base_typeis(x: object) -> TypeIs[Base]:
return isinstance(x, Base)
def use_typeguard(x: Child | Unrelated) -> None:
if is_base_typeguard(x):
reveal_type(x) # Base
else:
reveal_type(x) # Child | Unrelated
def use_typeis(x: Child | Unrelated) -> None:
if is_base_typeis(x):
reveal_type(x) # Child
else:
reveal_type(x) # Unrelated
Reference Implementation
========================
A draft implementation for mypy `is available <https://github.com/python/mypy/pull/16898>`__.
The ``TypeIs`` special form `has been implemented <https://github.com/python/typing_extensions/pull/330>`__
in the ``typing_extensions`` module and will be released in typing_extensions 4.10.0.
Implementations are available for several type checkers:
- Mypy: `pull request open <https://github.com/python/mypy/pull/16898>`__
- Pyanalyze: `pull request <https://github.com/quora/pyanalyze/pull/718>`__
- Pyright: `added in version 1.1.351 <https://github.com/microsoft/pyright/releases/tag/1.1.351>`__
Rejected Ideas
==============