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.
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. The runtime bodies are byte-for-byte identical — the only thing that changes is the annotation in the return position, and therefore the type the checker infers when the predicate returns False.
# 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. That negative branch is computed as a set difference: the input type minus the guarded type. For int | str minus str the result is a clean int, but the subtraction is only exact when the guarded type is one of the union members. A guarded type that partly overlaps the input leaves a residue rather than a crisp complement:
# Python 3.13+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypeIs
def is_str(x: int | str | bytes) -> TypeIs[str]:
return isinstance(x, str)
def route(x: int | str | bytes) -> None:
if is_str(x):
reveal_type(x) # str
else:
reveal_type(x) # int | bytes <- str removed, the rest survives
Note that reveal_type is not a real runtime function — both mypy and pyright special-case it during analysis, and it raises NameError if the module is actually executed. It exists only to make the inferred type visible in checker output. The narrowing itself survives from __future__ import annotations: postponed evaluation turns the annotation string lazy at runtime, but a checker parses the source directly, so -> TypeIs[str] is understood whether or not the annotation is stringized. The one runtime consequence of stringized annotations is that typing.get_type_hints() must resolve TypeIs/TypeGuard from the module namespace, so the name has to be importable even when you use it only for typing — a detail that matters if you gate the import behind if TYPE_CHECKING:. At runtime the function still just returns a plain bool; PEP 742 places no obligation on the body beyond that, and the special form contributes nothing to __annotations__ beyond the wrapped type.
A subtlety worth internalising is how the set difference interacts with None. Narrowing an Optional[str] — that is, str | None — with TypeIs[str] yields None in the else branch, which is exactly what you want for a “is this present and a string” predicate. But narrowing with a guard that returns False for some str values (for example, an “is non-empty string” test) breaks the clean partition: the guarded type is still str, so the else becomes str | None, not None, because an empty string is a str for which the guard returned False. The checker subtracts the declared type, never the runtime predicate. This is a frequent source of confusion — the negative branch reflects what the annotation claims, not what the body actually filters. Because TypeIs only reached the stdlib typing module in Python 3.13, code targeting 3.8–3.12 must import it from typing_extensions, which backports the identical semantics so the same predicate type-checks on every supported interpreter.
The subtype requirement
TypeIs[X] is only legal when X is consistent with the parameter type — informally, when X is a subtype of (or the same as) the value being narrowed. That constraint is exactly what licenses two-way narrowing: if X sits inside the input type, then “input minus X” is a meaningful else type. TypeGuard imposes no such rule, so it is free to narrow to something that lies partly or wholly outside the input.
# 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
The check is on consistency, not strict nominal subclassing, so TypeIs targets that involve Any are accepted in both directions — def f(x: object) -> TypeIs[int] is fine because int is a subtype of object, and def f(x: Any) -> TypeIs[int] is fine because Any is consistent with everything. The rule bites on genuinely disjoint types like int and str. It also applies element-wise through generics: TypeIs[Sequence[str]] on a Sequence[object] parameter is legal because Sequence is covariant and Sequence[str] really is a subtype of Sequence[object]. The moment you reach for an invariant container the subtype relation collapses, which is the entire reason the next section exists.
Structural types follow the same consistency rule. A TypeIs[SupportsClose] where SupportsClose is a Protocol is legal on any input whose members are consistent with that protocol — the checker asks “is the protocol a subtype of the parameter type?” using structural assignability, not the class hierarchy. That means a protocol-typed TypeIs is accepted when the input is object (everything is consistent with object) but rejected when the input is a concrete class that the protocol does not describe. TypeIs can also narrow the receiver of a method: defined on a class, the first positional parameter is self, so a method-form guard narrows the instance, but the subtype rule then compares the guarded type against self’s declared type, which is rarely what you want — a free function taking the value explicitly is clearer. When you are unsure whether a target qualifies, ask whether an isinstance-style test could ever narrow that way at runtime: if the value could be the target only by also being the input, TypeIs is sound; if narrowing would require reinterpreting the value as an unrelated type, it is not, and you want TypeGuard. See writing custom type-narrowing functions for the step-by-step choice.
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] because list is invariant in its element type (see why list is invariant but Sequence is covariant). Invariance means neither list[str] <: list[object] nor the reverse holds, so a set-difference else branch would be meaningless — precisely the situation PEP 647 was designed for.
# 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; swapping in TypeIs[list[str]] fails with [narrowed-type-not-subtype]. The same reasoning covers any “parse one representation into another” predicate — narrowing a str to a Literal shape it happens to match, a bytes blob to a list[int], or a raw dict[str, object] to a specific TypedDict. None of those targets is a subtype of the input, so only TypeGuard accepts them:
# Python 3.10+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypeGuard, TypedDict
class User(TypedDict):
id: int
name: str
def is_user(d: dict[str, object]) -> TypeGuard[User]:
return isinstance(d.get("id"), int) and isinstance(d.get("name"), str)
def greet(payload: dict[str, object]) -> None:
if is_user(payload):
reveal_type(payload) # User
print(payload["name"])
The trade-off is that the else branch stays at dict[str, object], and an unsound body — one that returns True without actually validating every key — is trusted without complaint. TypeGuard buys flexibility at the cost of the negative-branch guarantee.
TypeGuard is also the tool of choice when the guarded type is a Literal refinement. A predicate like def is_get(m: str) -> TypeGuard[Literal["GET"]] narrows a broad str to a specific literal — Literal["GET"] is consistent with str, so TypeIs would actually accept it here, but the moment the literal target does not partition the input cleanly (say narrowing str to Literal["GET", "POST"] while the input can be any HTTP verb), TypeGuard keeps the else sensible instead of producing an awkward complement. A second common home for TypeGuard is higher-order use: passing a guard to filter() narrows the element type of the resulting iterator, and because that call site involves no else branch, the one-way nature of TypeGuard costs you nothing:
# Python 3.10+, checked with pyright 1.1.370
from typing import TypeGuard
def is_int(x: object) -> TypeGuard[int]:
return isinstance(x, int)
values: list[object] = [1, "a", 2, None]
ints = [x for x in values if is_int(x)]
reveal_type(ints) # list[int] under pyright; mypy narrows the comprehension too
Because PEP 647 shipped in Python 3.10, TypeGuard is importable from the stdlib typing on every currently supported release, and from typing_extensions on 3.8/3.9. Unlike TypeIs, it has been stable in tooling for years, so it is the safer choice when your minimum supported interpreter predates 3.13 and you cannot add a typing_extensions dependency.
Analyzer behaviour
mypy (≥ 1.10) and pyright (≥ 1.1.360) both implement PEP 647 and PEP 742, but they landed the two PEPs at different times and label the soundness error differently. 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.
# Python 3.13+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypeIs
def is_positive(x: int) -> TypeIs[int]: # legal but pointless: same type
return x > 0
def use(x: int) -> None:
if is_positive(x):
reveal_type(x) # still int — narrowing to the same type is a no-op
else:
reveal_type(x) # int
A TypeIs whose target equals the input is accepted (it satisfies the subtype rule trivially) but does nothing useful — a value-level predicate like “is positive” cannot be expressed in the type system, so neither branch changes. Both checkers also agree that a TypeIs/TypeGuard return annotation is only special in return position: writing x: TypeIs[str] on a parameter or variable is a plain type error ([valid-type] in mypy, reportInvalidTypeForm in pyright). Where they still diverge is on generic guards — a TypeGuard[list[T]] whose T must be solved from the call — and on guards used as the first argument to filter() or passed as a Callable; pyright tends to propagate the narrowed type through higher-order calls more aggressively than mypy. Those inference gaps are catalogued in mypy vs pyright on TypeGuard. If you must support Python 3.12 or earlier, import both forms from typing_extensions; a bare from typing import TypeIs raises ImportError at runtime on any interpreter before 3.13, and — separately — the checker will not apply PEP 742 semantics if it cannot resolve the import.
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
The failure modes cluster around the subtype rule, the soundness contract, and version availability. Each has a distinct checker signature, so the error code usually tells you which trap you fell into.
- Using
TypeGuardwhen the predicate is a subtype test: you silently forfeitelse-branch narrowing. There is no error — the code type-checks — so the symptom is areveal_typein the negative branch that stays at the full union. Switch toTypeIsso theelseis refined. - Declaring a
TypeIswhose type is not a subtype of the input: mypy[narrowed-type-not-subtype], pyrightreportGeneralTypeIssues, raised at definition time before any call site. 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 as theif. A guard that returnsTruefor a value that is not the guarded type — orFalsefor one that is — poisons downstream code with no diagnostic. Keep the runtime check exhaustive. - Expecting
TypeIson Python 3.12 or earlier: it needs 3.13 in the stdlib, orfrom typing_extensions import TypeIs. Importing it fromtypingon an older interpreter raisesImportErrorat import time, long before any type-checking benefit. - Putting
TypeIs/TypeGuardanywhere but the return annotation: using it on a parameter, variable, or as a bare value is[valid-type](mypy) /reportInvalidTypeForm(pyright). The special form is only meaningful as the declared return type of a one-argument predicate.
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.