We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Type System as a Design Tool step 21 of 24
TypeIs, TypeGuard, and a predicate that lies
A validator written with TypeGuard leaves the else-branch completely
un-narrowed, so the error path gets no type information at all and people
cast their way out of it. TypeIs narrows both branches. It is almost always
the one you want, and it arrived seven years later, which is why so much
existing code has the wrong one.
The difference, precisely
For a predicate on a value of declared type A with a guard type R:
-
TypeGuard[R]narrows only the positive branch, to exactlyR. The negative branch keepsA, untouched.Rneed have no relationship toAat all. -
TypeIs[R]narrows both: the positive branch toA ∧ R, the negative branch toA ∧ ¬R. In exchange it requiresRto be assignable toA, and it is invariant in its argument.
Concretely, with x: str | int:
def g(x: object) -> TypeGuard[str]: ...
def i(x: object) -> TypeIs[str]: ...
if g(x): ... # x is str
else: ... # x is still str | int <- useless
if i(x): ... # x is str
else: ... # x is int <- useful
Both apply to the first positional argument only.
When TypeGuard is still correct
The PEP names exactly two cases, and they are worth keeping:
-
Narrowing to a non-subtype.
list[object]->list[int]is not a subtype relationship —listis invariant — soTypeIsis illegal andTypeGuardis the only option. - Partial predicates, where the negative branch genuinely does not exclude the guard type.
Case 2 is the trap this problem is built around.
The task
def is_str(x: object) -> TypeIs[str]: ...
def all_ints(xs: list[object]) -> TypeGuard[list[int]]: ...
def is_non_empty(s: str | None) -> TypeIs[str]: ... # deliberately wrong
def classify(items: list[object]) -> tuple[list[str], list[str]]: ...
def total(xs: list[object]) -> int: ...
def unsound(values: list[str | None]) -> list[str]: ...
def solve(
items: list[object],
numbers: list[object],
maybe: list[str | None],
) -> tuple[list[str], list[str], int, list[str]]: ...
classify partitions items: the uppercased strings, and
type(item).__name__ for everything else. is_str being a TypeIs is what
makes item.upper() legal in the positive branch — a TypeGuard would do that
too, but only TypeIs gives the negative branch a usable type instead of a bare
object.
total returns sum(xs) when all_ints holds, -1 otherwise. all_ints
tests type(x) is int — exact identity, so True is not an int for this
purpose, and neither is 2.0. Both make the whole list fail. Rewriting
all_ints to use TypeIs produces narrowed-type-not-subtype, because
list[int] is not assignable to list[object]: list is invariant, and that
is the first legitimate use of TypeGuard.
unsound is the demonstration. is_non_empty is a partial predicate
wearing a TypeIs annotation, so mypy narrows the negative branch to None —
and at runtime "" lands there anyway. The function emits "set:<value>" in the
positive branch and "missing:<repr>" in the negative one, so the output
contains the literal string missing:''. That output is the unsoundness,
written down: a value the checker has proved is None, whose repr is ''.
solve returns all four results.
What to take away
TypeIs is a stronger promise than TypeGuard, and Python cannot check that
you kept it. The annotation says “this returns True if and only if the value
is an R“. If your predicate also checks something else — non-empty, in range,
already validated — then it is partial, the negative branch narrowing is a lie,
and you want TypeGuard (or a different return type entirely, such as
str | None).
Read is_non_empty and ask what its name promises versus what its annotation
promises. They are not the same function.
Version note
typing.TypeIs requires Python 3.13. On 3.12 it is available from
typing_extensions. This problem targets 3.13 directly; if your daemon reports
3.12, the import will fail before mypy even runs.
Loading visualization…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.