TypeIs vs TypeGuard: PEP 742 Narrowing

TL;DR

TypeIs (PEP 742, Python 3.13) narrows in both the if and else branches, exactly like a built-in isinstance test. TypeGuard (PEP 647, Python 3.10) narrows only the positive branch and can even widen — its guarded type need not be a subtype of the input. Prefer TypeIs when your predicate is a genuine subtype test; keep TypeGuard only when you narrow to a type that is not a subtype of the parameter.

Both decorators let you teach a static checker your own narrowing rule, so a helper like is_ready(obj) refines the type the way isinstance would. The decisive difference is what happens in the else branch. This page is part of TypeGuard and Type Narrowing and focuses squarely on the PEP 742 vs PEP 647 choice.

TypeIs two-way narrowing versus TypeGuard one-way narrowing For an int or str input, TypeIs narrows the else branch to int while TypeGuard leaves the else branch as int or str. value: int | str TypeIs[str] if → str else → int (narrowed) TypeGuard[str] if → str else → int | str (kept)
TypeIs refines both branches; TypeGuard leaves the else branch at the declared type.

The else-branch difference in one example

Write the same predicate two ways and inspect both branches. With TypeIs, the else branch subtracts the guarded type from the input; with TypeGuard it does not.

# Python 3.13+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypeIs, TypeGuard

def is_str_v(value: int | str) -> TypeIs[str]:
    return isinstance(value, str)

def is_str_g(value: int | str) -> TypeGuard[str]:
    return isinstance(value, str)

def with_typeis(value: int | str) -> None:
    if is_str_v(value):
        reveal_type(value)   # str
    else:
        reveal_type(value)   # int   <- narrowed

def with_typeguard(value: int | str) -> None:
    if is_str_g(value):
        reveal_type(value)   # str
    else:
        reveal_type(value)   # int | str   <- NOT narrowed

The two functions have identical runtime bodies; only the annotation changes what the checker infers in the else. When the predicate really is a subtype test — “is this int | str actually a str?” — TypeIs gives you the negative branch for free.

The subtype requirement

TypeIs[X] is only legal when X is consistent with the parameter type. That constraint is precisely what allows two-way narrowing: if X is a subtype of the input, the input minus X is a meaningful else type. TypeGuard imposes no such rule, so it may narrow to something unrelated.

# Python 3.13+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypeIs

def bad(value: int) -> TypeIs[str]:   # str is not consistent with int
    return False
# mypy:    error: Narrowed type "str" is not a subtype of input type "int"  [narrowed-type-not-subtype]
# pyright: error: TypeIs return type must be assignable to the value type  reportGeneralTypeIssues

When TypeGuard is still the right tool

Because TypeGuard allows the guarded type to sit outside the input type, it handles conversions that TypeIs rejects — the classic case being “narrow list[object] to list[str],” where list[str] is not a subtype of list[object] (lists are invariant, see variance and type parameters).

# Python 3.13+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypeGuard

def all_strings(items: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(x, str) for x in items)

def dump(items: list[object]) -> None:
    if all_strings(items):
        reveal_type(items)   # list[str]
    # TypeIs[list[str]] here would be rejected: not a subtype of list[object]

Here TypeGuard is correct precisely because you are asserting a narrower element type that the invariance rules would otherwise forbid. Trying to use TypeIs fails with [narrowed-type-not-subtype].

Analyzer behaviour

mypy (≥ 1.10) and pyright (≥ 1.1.360) both implement PEP 647 and PEP 742. mypy reports an unsound TypeIs with the named code [narrowed-type-not-subtype]; pyright reports it as reportGeneralTypeIssues prose. Both narrow the positive branch of a TypeGuard identically and both refine both branches of a TypeIs. Minor inference divergences on generic guards are catalogued in mypy vs pyright on TypeGuard.

Runtime vs static analysis Both TypeIs and TypeGuard are erased at runtime — each function simply returns a bool. If your body returns True for a value that is not really the guarded type, both checkers still trust it and the mistake surfaces only as a crash later. The two-way narrowing of TypeIs makes an incorrect body more dangerous, because the else branch is trusted too.

Common mistakes

  • Using TypeGuard when the predicate is a subtype test: you silently forfeit else-branch narrowing. Switch to TypeIs so reveal_type in the negative branch is refined.
  • Declaring a TypeIs whose type is not a subtype of the input: mypy [narrowed-type-not-subtype], pyright reportGeneralTypeIssues. Either fix the guarded type or fall back to TypeGuard.
  • Trusting an incorrect TypeIs body: because both branches are narrowed, a wrong body corrupts the else path as well. Keep the runtime check exhaustive.
  • Expecting TypeIs on Python 3.12: it needs 3.13, or import from typing_extensions. Using it under an older typing raises ImportError at runtime.

FAQ

If TypeIs is stricter and gives more narrowing, why keep TypeGuard? Because TypeGuard intentionally allows the guarded type to not be a subtype of the input — narrowing list[object] to list[str], or parsing one representation into another. Those cases are illegal for TypeIs, so TypeGuard remains the only option there.

Does TypeIs change anything about the positive branch versus TypeGuard? No. Both narrow the if branch to the guarded type in the same way. The only behavioural difference is the else branch (narrowed for TypeIs, unchanged for TypeGuard) and the subtype constraint that TypeIs enforces at definition time.

Back to TypeGuard and Type Narrowing