2024-02-10 23:14:42 -05:00
|
|
|
PEP: 742
|
2024-02-15 20:16:03 -05:00
|
|
|
Title: Narrowing types with TypeIs
|
2024-02-10 23:14:42 -05:00
|
|
|
Author: Jelle Zijlstra <jelle.zijlstra@gmail.com>
|
2024-02-15 20:16:03 -05:00
|
|
|
Discussions-To: https://discuss.python.org/t/pep-742-narrowing-types-with-typenarrower/45613
|
2024-02-10 23:14:42 -05:00
|
|
|
Status: Draft
|
|
|
|
Type: Standards Track
|
|
|
|
Topic: Typing
|
|
|
|
Created: 07-Feb-2024
|
|
|
|
Python-Version: 3.13
|
2024-02-15 20:16:03 -05:00
|
|
|
Post-History: `11-Feb-2024 <https://discuss.python.org/t/pep-742-narrowing-types-with-typenarrower/45613>`__
|
2024-02-10 23:14:42 -05:00
|
|
|
Replaces: 724
|
|
|
|
|
|
|
|
|
|
|
|
Abstract
|
|
|
|
========
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
This PEP proposes a new special form, ``TypeIs``, to allow annotating functions that can be used
|
2024-02-10 23:14:42 -05:00
|
|
|
to narrow the type of a value, similar to the builtin :py:func:`isinstance`. Unlike the existing
|
2024-02-15 20:16:03 -05:00
|
|
|
:py:data:`typing.TypeGuard` special form, ``TypeIs`` can narrow the type in both the ``if``
|
2024-02-10 23:14:42 -05:00
|
|
|
and ``else`` branches of a conditional.
|
|
|
|
|
|
|
|
|
|
|
|
Motivation
|
|
|
|
==========
|
|
|
|
|
|
|
|
Typed Python code often requires users to narrow the type of a variable based on a conditional.
|
|
|
|
For example, if a function accepts a union of two types, it may use an :py:func:`isinstance` check
|
|
|
|
to discriminate between the two types. Type checkers commonly support type narrowing based on various
|
|
|
|
builtin function and operations, but occasionally, it is useful to use a user-defined function to
|
|
|
|
perform type narrowing.
|
|
|
|
|
|
|
|
To support such use cases, :pep:`647` introduced the :py:data:`typing.TypeGuard` special form, which
|
|
|
|
allows users to define type guards::
|
|
|
|
|
|
|
|
from typing import assert_type, TypeGuard
|
|
|
|
|
|
|
|
def is_str(x: object) -> TypeGuard[str]:
|
|
|
|
return isinstance(x, str)
|
|
|
|
|
|
|
|
def f(x: object) -> None:
|
|
|
|
if is_str(x):
|
|
|
|
assert_type(x, str)
|
|
|
|
else:
|
|
|
|
assert_type(x, object)
|
|
|
|
|
|
|
|
Unfortunately, the behavior of :py:data:`typing.TypeGuard` has some limitations that make it
|
|
|
|
less useful for many common use cases, as explained also in the "Motivation" section of :pep:`724`.
|
|
|
|
In particular:
|
|
|
|
|
|
|
|
* Type checkers must use exactly the ``TypeGuard`` return type as the narrowed type if the
|
|
|
|
type guard returns ``True``. They cannot use pre-existing knowledge about the type of the
|
|
|
|
variable.
|
|
|
|
* In the case where the type guard returns ``False``, the type checker cannot apply any
|
|
|
|
additional narrowing.
|
|
|
|
|
|
|
|
The standard library function :py:func:`inspect.isawaitable` may serve as an example. It
|
|
|
|
returns whether the argument is an awaitable object, and
|
|
|
|
`typeshed <https://github.com/python/typeshed/blob/a4f81a67a07c18dd184dd068c459b02e71bcac22/stdlib/inspect.pyi#L219>`__
|
|
|
|
currently annotates it as::
|
|
|
|
|
|
|
|
def isawaitable(object: object) -> TypeGuard[Awaitable[Any]]: ...
|
|
|
|
|
|
|
|
A user `reported <https://github.com/python/mypy/issues/15520>`__ an issue to mypy about
|
|
|
|
the behavior of this function. They observed the following behavior::
|
|
|
|
|
|
|
|
import inspect
|
|
|
|
from collections.abc import Awaitable
|
|
|
|
from typing import reveal_type
|
|
|
|
|
|
|
|
async def f(t: Awaitable[int] | int) -> None:
|
|
|
|
if inspect.isawaitable(t):
|
|
|
|
reveal_type(t) # Awaitable[Any]
|
|
|
|
else:
|
|
|
|
reveal_type(t) # Awaitable[int] | int
|
|
|
|
|
|
|
|
This behavior is consistent with :pep:`647`, but it did not match the user's expectations.
|
|
|
|
Instead, they would expect the type of ``t`` to be narrowed to ``Awaitable[int]`` in the ``if``
|
|
|
|
branch, and to ``int`` in the ``else`` branch. This PEP proposes a new construct that does
|
|
|
|
exactly that.
|
|
|
|
|
|
|
|
Other examples of issues that arose out of the current behavior of ``TypeGuard`` include:
|
|
|
|
|
|
|
|
* `Python typing issue <https://github.com/python/typing/issues/996>`__ (``numpy.isscalar``)
|
|
|
|
* `Python typing issue <https://github.com/python/typing/issues/1351>`__ (:py:func:`dataclasses.is_dataclass`)
|
|
|
|
* `Pyright issue <https://github.com/microsoft/pyright/issues/3450>`__ (expecting :py:data:`typing.TypeGuard` to work like :py:func:`isinstance`)
|
|
|
|
* `Pyright issue <https://github.com/microsoft/pyright/issues/3466>`__ (expecting narrowing in the ``else`` branch)
|
|
|
|
* `Mypy issue <https://github.com/python/mypy/issues/13957>`__ (expecting narrowing in the ``else`` branch)
|
|
|
|
* `Mypy issue <https://github.com/python/mypy/issues/14434>`__ (combining multiple TypeGuards)
|
|
|
|
* `Mypy issue <https://github.com/python/mypy/issues/15305>`__ (expecting narrowing in the ``else`` branch)
|
|
|
|
* `Mypy issue <https://github.com/python/mypy/issues/11907>`__ (user-defined function similar to :py:func:`inspect.isawaitable`)
|
|
|
|
* `Typeshed issue <https://github.com/python/typeshed/issues/8009>`__ (``asyncio.iscoroutinefunction``)
|
|
|
|
|
|
|
|
Rationale
|
|
|
|
=========
|
|
|
|
|
|
|
|
The problems with the current behavior of :py:data:`typing.TypeGuard` compel us to improve
|
|
|
|
the type system to allow a different type narrowing behavior. :pep:`724` proposed to change
|
|
|
|
the behavior of the existing :py:data:`typing.TypeGuard` construct, but we :ref:`believe <pep-742-change-typeguard>`
|
|
|
|
that the backwards compatibility implications of that change are too severe. Instead, we propose
|
|
|
|
adding a new special form with the desired semantics.
|
|
|
|
|
|
|
|
We acknowledge that this leads to an unfortunate situation where there are two constructs with
|
|
|
|
a similar purpose and similar semantics. We believe that users are more likely to want the behavior
|
2024-02-15 20:16:03 -05:00
|
|
|
of ``TypeIs``, the new form proposed in this PEP, and therefore we recommend that documentation
|
|
|
|
emphasize ``TypeIs`` over ``TypeGuard`` as a more commonly applicable tool. However, the semantics of
|
2024-02-10 23:14:42 -05:00
|
|
|
``TypeGuard`` are occasionally useful, and we do not propose to deprecate or remove it. In the long
|
2024-02-15 20:16:03 -05:00
|
|
|
run, most users should use ``TypeIs``, and ``TypeGuard`` should be reserved for rare cases
|
2024-02-10 23:14:42 -05:00
|
|
|
where its behavior is specifically desired.
|
|
|
|
|
|
|
|
|
|
|
|
Specification
|
|
|
|
=============
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
A new special form, ``TypeIs``, is added to the :py:mod:`typing`
|
2024-02-10 23:14:42 -05:00
|
|
|
module. Its usage, behavior, and runtime implementation are similar to
|
|
|
|
those of :py:data:`typing.TypeGuard`.
|
|
|
|
|
|
|
|
It accepts a single
|
|
|
|
argument and can be used as the return type of a function. A function annotated as returning a
|
2024-02-15 20:16:03 -05:00
|
|
|
``TypeIs`` is called a type narrowing function. Type narrowing functions must return ``bool``
|
2024-02-10 23:14:42 -05:00
|
|
|
values, and the type checker should verify that all return paths return
|
|
|
|
``bool``.
|
|
|
|
|
|
|
|
Type narrowing functions must accept at least one positional argument. The type
|
|
|
|
narrowing behavior is applied to the first positional argument passed to
|
|
|
|
the function. The function may accept additional arguments, but they are
|
|
|
|
not affected by type narrowing. If a type narrowing function is implemented as
|
|
|
|
an instance method or class method, the first positional argument maps
|
|
|
|
to the second parameter (after ``self`` or ``cls``).
|
|
|
|
|
|
|
|
Type narrowing behavior
|
|
|
|
-----------------------
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
To specify the behavior of ``TypeIs``, we use the following terminology:
|
2024-02-10 23:14:42 -05:00
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
* I = ``TypeIs`` input type
|
|
|
|
* R = ``TypeIs`` return type
|
2024-02-10 23:14:42 -05:00
|
|
|
* A = Type of argument passed to type narrowing function (pre-narrowed)
|
2024-02-15 20:16:03 -05:00
|
|
|
* NP = Narrowed type (positive; used when ``TypeIs`` returned ``True``)
|
|
|
|
* NN = Narrowed type (negative; used when ``TypeIs`` returned ``False``)
|
2024-02-10 23:14:42 -05:00
|
|
|
|
|
|
|
.. code-block:: python
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
def narrower(x: I) -> TypeIs[R]: ...
|
2024-02-10 23:14:42 -05:00
|
|
|
|
|
|
|
def func1(val: A):
|
|
|
|
if narrower(val):
|
|
|
|
assert_type(val, NP)
|
|
|
|
else:
|
|
|
|
assert_type(val, NN)
|
|
|
|
|
|
|
|
The return type ``R`` must be :ref:`consistent with <pep-483-gradual-typing>` ``I``. The type checker should
|
|
|
|
emit an error if this condition is not met.
|
|
|
|
|
|
|
|
Formally, type *NP* should be narrowed to :math:`A \land R`,
|
|
|
|
the intersection of *A* and *R*, and type *NN* should be narrowed to
|
|
|
|
:math:`A \land \neg R`, the intersection of *A* and the complement of *R*.
|
|
|
|
In practice, the theoretic types for strict type guards cannot be expressed
|
|
|
|
precisely in the Python type system. Type checkers should fall back on
|
|
|
|
practical approximations of these types. As a rule of thumb, a type checker
|
|
|
|
should use the same type narrowing logic -- and get results that are consistent
|
|
|
|
with -- its handling of :py:func:`isinstance`. This guidance allows for changes and
|
|
|
|
improvements if the type system is extended in the future.
|
|
|
|
|
|
|
|
Examples
|
|
|
|
--------
|
|
|
|
|
|
|
|
Type narrowing is applied in both the positive and negative case::
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
from typing import TypeIs, assert_type
|
2024-02-10 23:14:42 -05:00
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
def is_str(x: object) -> TypeIs[str]:
|
2024-02-10 23:14:42 -05:00
|
|
|
return isinstance(x, str)
|
|
|
|
|
|
|
|
def f(x: str | int) -> None:
|
|
|
|
if is_str(x):
|
|
|
|
assert_type(x, str)
|
|
|
|
else:
|
|
|
|
assert_type(x, int)
|
|
|
|
|
|
|
|
The final narrowed type may be narrower than **R**, due to the constraints of the
|
|
|
|
argument's previously-known type::
|
|
|
|
|
|
|
|
from collections.abc import Awaitable
|
2024-02-15 20:16:03 -05:00
|
|
|
from typing import Any, TypeIs, assert_type
|
2024-02-10 23:14:42 -05:00
|
|
|
import inspect
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
def isawaitable(x: object) -> TypeIs[Awaitable[Any]]:
|
2024-02-10 23:14:42 -05:00
|
|
|
return inspect.isawaitable(x)
|
|
|
|
|
|
|
|
def f(x: Awaitable[int] | int) -> None:
|
|
|
|
if isawaitable(x):
|
|
|
|
assert_type(x, Awaitable[int])
|
|
|
|
else:
|
|
|
|
assert_type(x, int)
|
|
|
|
|
|
|
|
It is an error to narrow to a type that is not consistent with the input type::
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
from typing import TypeIs
|
2024-02-10 23:14:42 -05:00
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
def is_str(x: int) -> TypeIs[str]: # Type checker error
|
2024-02-10 23:14:42 -05:00
|
|
|
...
|
|
|
|
|
|
|
|
Subtyping
|
|
|
|
---------
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
``TypeIs`` is not a subtype of ``bool``.
|
|
|
|
The type ``Callable[..., TypeIs[int]]`` is not assignable to
|
2024-02-10 23:14:42 -05:00
|
|
|
``Callable[..., bool]`` or ``Callable[..., TypeGuard[int]]``, and vice versa.
|
|
|
|
This restriction is carried over from :pep:`647`. It may be possible to relax
|
|
|
|
it in the future, but that is outside the scope of this PEP.
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
Unlike ``TypeGuard``, ``TypeIs`` is invariant in its argument type:
|
|
|
|
``TypeIs[B]`` is not a subtype of ``TypeIs[A]``,
|
2024-02-10 23:14:42 -05:00
|
|
|
even if ``B`` is a subtype of ``A``.
|
|
|
|
To see why, consider the following example::
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
def takes_narrower(x: int | str, narrower: Callable[[object], TypeIs[int]]):
|
2024-02-10 23:14:42 -05:00
|
|
|
if narrower(x):
|
|
|
|
print(x + 1) # x is an int
|
|
|
|
else:
|
|
|
|
print("Hello " + x) # x is a str
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
def is_bool(x: object) -> TypeIs[bool]:
|
2024-02-10 23:14:42 -05:00
|
|
|
return isinstance(x, bool)
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
takes_narrower(1, is_bool) # Error: is_bool is not a TypeIs[int]
|
2024-02-10 23:14:42 -05:00
|
|
|
|
|
|
|
(Note that ``bool`` is a subtype of ``int``.)
|
|
|
|
This code fails at runtime, because the narrower returns ``False`` (1 is not a ``bool``)
|
|
|
|
and the ``else`` branch is taken in ``takes_narrower()``.
|
|
|
|
If the call ``takes_narrower(1, is_bool)`` was allowed, type checkers would fail to
|
|
|
|
detect this error.
|
|
|
|
|
|
|
|
Backwards Compatibility
|
|
|
|
=======================
|
|
|
|
|
|
|
|
As this PEP only proposes a new special form, there are no implications on
|
|
|
|
backwards compatibility.
|
|
|
|
|
|
|
|
|
|
|
|
Security Implications
|
|
|
|
=====================
|
|
|
|
|
|
|
|
None known.
|
|
|
|
|
|
|
|
|
|
|
|
How to Teach This
|
|
|
|
=================
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
Introductions to typing should cover ``TypeIs`` when discussing how to narrow types,
|
2024-02-10 23:14:42 -05:00
|
|
|
along with discussion of other narrowing constructs such as :py:func:`isinstance`. The
|
2024-02-15 20:16:03 -05:00
|
|
|
documentation should emphasize ``TypeIs`` over :py:data:`typing.TypeGuard`; while the
|
2024-02-10 23:14:42 -05:00
|
|
|
latter is not being deprecated and its behavior is occasionally useful, we expect that the
|
2024-02-15 20:16:03 -05:00
|
|
|
behavior of ``TypeIs`` is usually more intuitive, and most users should reach for
|
|
|
|
``TypeIs`` first.
|
2024-02-10 23:14:42 -05:00
|
|
|
|
|
|
|
|
|
|
|
Reference Implementation
|
|
|
|
========================
|
|
|
|
|
|
|
|
A draft implementation for mypy `is available <https://github.com/python/mypy/pull/16898>`__.
|
|
|
|
|
|
|
|
|
|
|
|
Rejected Ideas
|
|
|
|
==============
|
|
|
|
|
|
|
|
.. _pep-742-change-typeguard:
|
|
|
|
|
|
|
|
Change the behavior of ``TypeGuard``
|
|
|
|
------------------------------------
|
|
|
|
|
|
|
|
:pep:`724` previously proposed changing the specified behavior of :py:data:`typing.TypeGuard` so
|
|
|
|
that if the return type of the guard is consistent with the input type, the behavior proposed
|
2024-02-15 20:16:03 -05:00
|
|
|
here for ``TypeIs`` would apply. This proposal has some important advantages: because it
|
2024-02-10 23:14:42 -05:00
|
|
|
does not require any runtime changes, it requires changes only in type checkers, making it easier
|
|
|
|
for users to take advantage of the new, usually more intuitive behavior.
|
|
|
|
|
|
|
|
However, this approach has some major problems. Users who have written ``TypeGuard`` functions
|
|
|
|
expecting the existing semantics specified in :pep:`647` would see subtle and potentially breaking
|
|
|
|
changes in how type checkers interpret their code. The split behavior of ``TypeGuard``, where it
|
|
|
|
works one way if the return type is consistent with the input type and another way if it is not,
|
|
|
|
could be confusing for users. The Typing Council was unable to come to an agreement in favor of
|
|
|
|
:pep:`724`; as a result, we are proposing this alternative PEP.
|
|
|
|
|
|
|
|
Do nothing
|
|
|
|
----------
|
|
|
|
|
|
|
|
Both this PEP and the alternative proposed in :pep:`724` have shortcomings. The latter are
|
|
|
|
discussed above. As for this PEP, it introduces two special forms with very similar semantics,
|
|
|
|
and it potentially creates a long migration path for users currently using ``TypeGuard``
|
|
|
|
who would be better off with different narrowing semantics.
|
|
|
|
|
|
|
|
One way forward, then, is to do nothing and live with the current limitations of the type system.
|
|
|
|
However, we believe that the limitations of the current ``TypeGuard``, as outlined in the "Motivation"
|
|
|
|
section, are significant enough that it is worthwhile to change the type system to address them.
|
|
|
|
If we do not make any change, users will continue to encounter the same unintuitive behaviors from
|
|
|
|
``TypeGuard``, and the type system will be unable to properly represent common type narrowing functions
|
|
|
|
like ``inspect.isawaitable``.
|
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
Alternative names
|
|
|
|
-----------------
|
2024-02-10 23:14:42 -05:00
|
|
|
|
2024-02-15 20:16:03 -05:00
|
|
|
This PEP currently proposes the name ``TypeIs``, emphasizing that the special form ``TypeIs[T]``
|
|
|
|
returns whether the argument is of type ``T``, and mirroring
|
|
|
|
`TypeScript's syntax <https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates>`__.
|
|
|
|
Other names were considered, including in an earlier version of this PEP.
|
2024-02-10 23:14:42 -05:00
|
|
|
|
|
|
|
Options include:
|
|
|
|
|
|
|
|
* ``IsInstance`` (`post by Paul Moore <https://discuss.python.org/t/pep-724-stricter-type-guards/34124/60>`__):
|
|
|
|
emphasizes that the new construct behaves similarly to the builtin :py:func:`isinstance`.
|
|
|
|
* ``Narrowed`` or ``NarrowedTo``: shorter than ``TypeNarrower`` but keeps the connection to "type narrowing"
|
|
|
|
(suggested by Eric Traut).
|
|
|
|
* ``Predicate`` or ``TypePredicate``: mirrors TypeScript's name for the feature, "type predicates".
|
|
|
|
* ``StrictTypeGuard`` (earlier drafts of :pep:`724`): emphasizes that the new construct performs a stricter
|
|
|
|
version of type narrowing than :py:data:`typing.TypeGuard`.
|
|
|
|
* ``TypeCheck`` (`post by Nicolas Tessore <https://discuss.python.org/t/pep-724-stricter-type-guards/34124/59>`__):
|
|
|
|
emphasizes the binary nature of the check.
|
2024-02-15 20:16:03 -05:00
|
|
|
* ``TypeNarrower``: emphasizes that the function narrows its argument type. Used in an earlier version of this PEP.
|
2024-02-10 23:14:42 -05:00
|
|
|
|
|
|
|
Acknowledgments
|
|
|
|
===============
|
|
|
|
|
|
|
|
Much of the motivation and specification for this PEP derives from :pep:`724`. While
|
|
|
|
this PEP proposes a different solution for the problem at hand, the authors of :pep:`724`, Eric Traut, Rich
|
|
|
|
Chiodo, and Erik De Bonte, made a strong case for their proposal and this proposal
|
|
|
|
would not have been possible without their work.
|
|
|
|
|
|
|
|
|
|
|
|
Copyright
|
|
|
|
=========
|
|
|
|
|
|
|
|
This document is placed in the public domain or under the
|
|
|
|
CC0-1.0-Universal license, whichever is more permissive.
|