Skip to content

Commit

Permalink
Add additional examples about TypeIs
Browse files Browse the repository at this point in the history
  • Loading branch information
mikeshardmind committed Jul 20, 2024
1 parent 75caed7 commit d3bb2d2
Showing 1 changed file with 75 additions and 0 deletions.
75 changes: 75 additions & 0 deletions docs/spec/narrowing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,78 @@ This code fails at runtime, because the narrower returns ``False`` (1 is not a `
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.

In some cases, it may not be possible to narrow a type fully from information
available to the TypeIs function. In such cases, raising an error is the only
possible option, as you have neither enough information to confirm or deny a
type narrowing operation. This is most likely to occur with narrowing of generics.

To see why, we can look at the following example::

from typing_extensions import TypeVar, TypeIs
from typing import Generic

X = TypeVar("X", str, int, str | int, covariant=True, default=str | int)

class A(Generic[X]):
def __init__(self, i: X, /):
self._i: X = i

@property
def i(self) -> X:
return self._i


class B(A[X], Generic[X]):
def __init__(self, i: X, j: X, /):
super().__init__(i)
self._j: X = j

@property
def j(self) -> X:
return self._j

def possible_problem(x: A) -> TypeIs[A[int]]:
return isinstance(x.i, int)

def possible_correction(x: A) -> TypeIs[A[int]]:
if type(x) is A:
# only narrow cases we know about
return isinstance(x.i, int)
raise TypeError(
f"Refusing to narrow Genenric type {type(x)!r}"
f"from function that only knows about {A!r}"
)

Because it is possible to attempt to narrow B,
but A does not have appropriate information about B
(or any other unknown subclass of A!) it's not possible to safely narrow
in either direction. The general rule for generics is that if you do not know
all the places a generic class is generic and do not enough of them to be
absolutely certain, you cannot return True, and if you do not have a definitive
counter example to the type to be narrowed to you cannot return False.
In practice, if soundness is prioritized over an unsafe narrowing,
not knowing what you don't know is solvable by erroring out
or by making the class to be narrowed final to avoid such a situation.

In practice, such correctness is not always neccessary, and may work against
your needs. for example, if you trust that users implementing
the Sequence Protocol are doing so in a way that is safe to iterate over,
the following function can never be fully sound, but fully soundness is not necessarily
easier or better for your use::

def useful_unsoundness(s: Sequence[object]) -> TypeIs[Sequence[int]]:
return all(isinstance(i, int) for i in s)

However, many cases of this sort can be extracted for safe use with an alternative construction
if soundness is of a high priority, and the cost of a copy is acceptable::

def safer(s: Sequence[object]) -> Sequence[int]:
ret = tuple(i for i in s if isinstance(i, int))
if len(ret) != len(s):
raise TypeError
return ret

Ultimately, TypeIs allows a very large amount of flexibility in handling type-narrowing,
at the cost of more of the issues of evaluating when it is use is safe being left
in the hands of developers.

0 comments on commit d3bb2d2

Please sign in to comment.