Type narrowing for TypeGuard in the negative case · python/typing · Discussion #1013
Based on feedback, it sounds like the above proposal does not sufficiently meet the needs or expectations of users who are dissatisfied with the current TypeGuard mechanism documented in PEP 647. Here's a discussion of another case where the current TypeGuard has been used incorrectly in a recent release of numpy, and the resulting behavior doesn't do what was intended.
Taking in all of the feedback, here's an alternative proposal that involves the introduction of another form of TypeGuard which has "strict" type narrowing semantics. It would be less flexible than the existing TypeGuard, but the added constraints would allow it to be used for these alternate use cases.
StrictTypeGuard
This proposal would introduce a new flavor of TypeGuard that would have strict narrowing semantics. We'd need to come up with a new name for this, perhaps StrictTypeGuard, ExactTypeGuard, or PreciseTypeGuard. Other suggestions are welcome.
This new flavor of type guard would be similar to the more flexible version defined in PEP 647 with the following three differences:
- The type checker would enforce the requirement that the type guard type (specified as a return type of the type guard function) is a subtype of the input type (the declared type of the first parameter to the type guard function). In other words, the type guard type must be strictly narrower than the input type. This precludes some of the use cases anticipated in the original PEP 647.
def is_marsupial(val: Animal) -> StrictTypeGuard[Kangaroo | Koala]: # This is allowed return isinstance(val, Kangaroo | Koala) def has_no_nones(val: list[T | None]) -> StrictTypeGuard[list[T]]: # Error: "list[T]" cannot be assigned to "list[T | None]" return None not in val
- Type narrowing would be applied in the negative ("else") case. This may still lead to incorrect assumptions, but it's less likely to be incorrect with restriction 1 in place. Consider, for example,
def is_black_cat(val: Animal) -> StrictTypeGuard[Cat]: return isinstance(val, Cat) and val.color == Color.Black def func(val: Cat | Dog): if is_black_cat(val): reveal_type(val) # Cat else: reveal_type(val) # Dog - which is potentially wrong here
- If the return type of the type guard function includes a union, the type checker would apply additional type narrowing based on the type of the argument passed to the type guard call, eliminating union elements that are impossible given the argument type. For example:
def is_cardinal_direction(val: str) -> StrictTypeGuard[Literal["N", "S", "E", "W"]]: return val in ("N", "S", "E", "W") def func(direction: Literal["NW", "E"]): if is_cardinal_direction(direction): reveal_type(direction) # Literal["E"] # The type cannot be "N", "S" or "W" here because of argument type else: reveal_type(direction) # Literal["NW"]
TypeAssert and StrictTypeAssert
As mentioned above, there has also been a desire to support a "type assert" function. This is similar to a "type guard" function except that it raises an exception (similar to an assert statement, except that its behavior is not dependent on 'debug' mode) if the input's type doesn't match the declared return type. This is analogous to "type predicate functions" supported in TypeScript, like the following:
function assertAuthenticated(user: User | null): asserts user is User { if (user === null) { throw new Error('Unauthenticated'); } }
We propose to add two new forms TypeAssert and StrictTypeAssert, which would be analogous to TypeGuard and StrictTypeGuard. While type guard functions are expected to return a bool value, "type assert" forms would return None (if the input type matches) or raise an exception (if the input type doesn't match). Type narrowing in the negative ("else") case doesn't apply for type assertions because it is assumed that an exception is raised in this event.
Here are some examples:
def verify_no_nones(val: list[None | T]) -> TypeAssert[list[T]]: if None in val: raise ValueError() def func(x: list[int | None]): verify_no_nones(x) reveal_type(x) # list[int]
and
def assert_is_one_dimensional(val: tuple[T, ...] | T) -> StrictTypeAssert[tuple[T] | T]: if isinstance(val, tuple) and len(val) != 1: raise ValueError() def func(x: float, y: tuple[float, ...]): assert_is_one_dimensional(x) reveal_type(x) # float assert_is_one_dimensional(y) reveal_type(y) # tuple[float]
Thoughts? Suggestions?
If we move forward with the above proposal (or some subset thereof), it will probably require a new PEP, as opposed to a modification to PEP 647.