TypeIs vs TypeGuard: PEP 742 Narrowing
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 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.
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
TypeGuardwhen the predicate is a subtype test: you silently forfeitelse-branch narrowing. Switch toTypeIssoreveal_typein the negative branch is refined. - Declaring a
TypeIswhose type is not a subtype of the input: mypy[narrowed-type-not-subtype], pyrightreportGeneralTypeIssues. Either fix the guarded type or fall back toTypeGuard. - Trusting an incorrect
TypeIsbody: because both branches are narrowed, a wrong body corrupts theelsepath as well. Keep the runtime check exhaustive. - Expecting
TypeIson Python 3.12: it needs 3.13, or import fromtyping_extensions. Using it under an oldertypingraisesImportErrorat 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.