Skip to content

← The Type System as a Design Tool step 21 of 24

Hard Framework

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 exactly R. The negative branch keeps A, untouched. R need have no relationship to A at all.
  • TypeIs[R] narrows both: the positive branch to A ∧ R, the negative branch to A ∧ ¬R. In exchange it requires R to be assignable to A, 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:

  1. Narrowing to a non-subtype. list[object] -> list[int] is not a subtype relationship — list is invariant — so TypeIs is illegal and TypeGuard is the only option.
  2. 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…