Python TypeGuard vs TypeIs: Understanding PEP 742
Python 3.13 introduced typing.TypeIs via PEP 742 to
solve long-standing type-narrowing limitations found in
typing.TypeGuard (introduced in PEP 647). While both
constructs allow developers to write user-defined type guard functions
that inspect objects at runtime, they handle conditional branching
differently. TypeGuard only narrows types in the positive
(if) branch and permits arbitrary type overrides, whereas
TypeIs enforces stricter subtyping rules and provides
symmetric narrowing across both the positive (if) and
negative (else) branches.
The Limitations of TypeGuard
Introduced in Python 3.10, TypeGuard allows functions to
return a boolean indicating whether an argument matches a specific type.
However, it suffers from two major limitations:
- No Negative Narrowing: When a
TypeGuardreturnsFalse, type checkers cannot infer anything about the type in theelsebranch. The variable retains its original, un-narrowed type. - Unsafe Type Overrides:
TypeGuarddoes not require the narrowed type to be a subtype of the input type. If a function acceptsobjectand assertsTypeGuard[int], the type checker blindly treats the object asintin theifblock, even if the runtime logic is flawed.
from typing import TypeGuard
def is_string(val: object) -> TypeGuard[str]:
return isinstance(val, str)
def process(val: str | int) -> None:
if is_string(val):
reveal_type(val) # str
else:
reveal_type(val) # str | int (TypeGuard cannot eliminate str here)In the example above, even though val can only be
int when is_string(val) is False,
the type checker still sees str | int.
How TypeIs Resolves These Issues
TypeIs addresses these shortcomings by modeling how
built-in checks like isinstance() actually work.
1. Symmetric Narrowing (Positive and Negative Branches)
When a function returning TypeIs[T] yields
True, the variable is narrowed to T. When it
yields False, the type checker removes T from
the candidate union in the else branch.
from typing import TypeIs
def is_string(val: object) -> TypeIs[str]:
return isinstance(val, str)
def process(val: str | int) -> None:
if is_string(val):
reveal_type(val) # str
else:
reveal_type(val) # int (str is safely removed from the union)2. Strict Subtyping Requirement
Unlike TypeGuard, TypeIs[T] requires
T to be consistent with the input type of the inspected
argument. The narrowed type is computed as the intersection of the
original type and T. If T cannot be a subtype
or specialization of the input type, static type checkers like Pyright
or Mypy will emit an error.
Key Differences Summary
| Feature | typing.TypeGuard |
typing.TypeIs |
|---|---|---|
| PEP Specification | PEP 647 (Python 3.10) | PEP 742 (Python 3.13) |
Positive Narrowing
(if) |
Replaces type with T |
Narrows via intersection of input and
T |
Negative Narrowing
(else) |
No narrowing occurs | Eliminates T from the
union |
| Subtyping Constraint | Not required (allows unsafe overrides) | Required (must be compatible with input type) |
When to Use Which
- Use
TypeIsfor almost all standard type predicates. It behaves intuitively, mirrorsisinstance(), and provides complete type safety across both execution paths. - Use
TypeGuardonly when you intentionally want asymmetric behavior—such as narrowing a broader type into a target type where the negative case cannot guarantee the exclusion of that target type, or when intentionally overriding types without strict subtyping checks.