TypeGuard, TypeIs & Type Narrowing in Python
Type narrowing is how a static analyzer refines a wide type like object or str | bytes into
a more specific one inside a branch of code. Python ships built-in narrowing for isinstance,
assert, and is None checks, and since PEP 647 it lets you teach the checker your own
narrowing rules with typing.TypeGuard (Python 3.10+) and, since PEP 742, the sounder
typing.TypeIs (Python 3.13+). This guide covers both special return forms, how mypy and pyright
consume them, the one-way vs two-way distinction, and the error codes you will see when a guard is
written incorrectly. It builds on the generic machinery in
Advanced Typing Patterns & Generics and pairs naturally with
Protocol and structural subtyping.
Built-in narrowing the checker already understands
Before reaching for a custom guard, remember that analyzers narrow automatically on a fixed set of
constructs. An isinstance() test, an is None / is not None comparison, an equality check
against a Literal,
and an assert statement all refine the type in the branches that follow.
# Python 3.11+, mypy 1.x / pyright 1.1.x
def render(value: str | None) -> str:
if value is None:
return "(empty)"
# value is now narrowed to str — no [union-attr] here
return value.upper()
def take_int(value: object) -> int:
assert isinstance(value, int) # narrows the rest of the function to int
return value + 1
The built-in set is larger than most code uses. type(x) is Circle narrows to exactly Circle,
unlike isinstance, which also admits subclasses; callable(x) narrows to a callable type; and
truthiness (if x:) strips None from an Optional[str]. The most valuable case is comparing a
Literal discriminant, which narrows a tagged union — the standard way to resolve a union of
TypedDicts:
# Python 3.11+, mypy 1.x / pyright 1.1.x
from typing import Literal, TypedDict
class Cat(TypedDict):
kind: Literal["cat"]
lives: int
class Dog(TypedDict):
kind: Literal["dog"]
good: bool
def describe(animal: Cat | Dog) -> str:
if animal["kind"] == "cat":
return f"{animal['lives']} lives" # narrowed to Cat
return "good boy" if animal["good"] else "bad" # narrowed to Dog
Structural pattern matching (match/case, PEP 634, Python 3.10+) narrows on class and value
patterns as well, so a case int(): block sees an int. The two checkers diverge on a couple of
constructs: pyright narrows hasattr(x, "foo") to a synthesised Protocol carrying that attribute,
whereas mypy does not narrow on hasattr at all. Both narrow is None, isinstance, assert, and
Literal equality identically.
These work because the checker has hard-coded rules for isinstance, is, and assert. The
problem appears when the test is hidden behind a helper such as is_valid(value) — the analyzer
cannot see inside the call and will not narrow; it only sees a bool come back. Narrowing is also
flow-sensitive: it is discarded the moment you reassign the variable, and it does not survive a
function-call boundary, since the checker cannot assume value is unchanged after an arbitrary call.
That is exactly the gap TypeGuard and TypeIs close — they let a helper return narrowing
information to its call site.
Syntax spec: TypeGuard and TypeIs return forms
A narrowing function returns bool at runtime, but its annotation is a special form. With
TypeGuard[X], a True return tells the checker the first positional argument is an X. With
TypeIs[X], a True return means the argument is an X and a False return means it is not.
# Python 3.13+, mypy 1.x / pyright 1.1.x
from typing import TypeGuard, TypeIs
def is_str_list(values: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(v, str) for v in values)
def is_str(value: object) -> TypeIs[str]:
return isinstance(value, str)
On Python 3.9–3.12 both forms are importable from typing_extensions (TypeGuard is also in
typing from 3.10; TypeIs from 3.13). The function must take at least one positional parameter —
the value being narrowed — and the guarded type goes inside the subscript.
The narrowed value is always the first positional parameter, but a guard may take more parameters
after it; they are ordinary arguments and play no part in narrowing. A method can be a guard too —
its first positional parameter is self, so a method guard narrows the receiver, which is seldom
what you want (prefer a free function). The guarded type in the subscript can be any type form:
a concrete class (TypeIs[int]), a generic alias (TypeGuard[list[str]]), a union
(TypeIs[int | float]), or a parameterised type using a TypeVar. Because the annotation is a
special form rather than a runtime value, from __future__ import annotations (PEP 563) does not
change how it is interpreted — it only defers evaluation of the string. At runtime the function is a
completely normal predicate: it must actually return a truthy or falsy value, and the special form
is erased, so a guard whose body forgets to return yields None (falsy) and silently narrows
nothing. Both mypy and pyright also require the return type to be exactly TypeGuard[...] or
TypeIs[...]; wrapping it, as in -> bool | TypeIs[str], is rejected as an invalid type.
TypeGuard vs TypeIs: one-way vs two-way narrowing
This is the distinction that matters most. TypeGuard is one-way: it narrows only the if
branch. The else branch keeps the original declared type, because a TypeGuard[list[str]] does
not promise that a False result means “not a list[str]” — the guarded type need not even be a
subtype of the input type.
# Python 3.11+, mypy 1.x / pyright 1.1.x
from typing import TypeGuard
def is_str_list(values: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(v, str) for v in values)
def handle(values: list[object]) -> None:
if is_str_list(values):
reveal_type(values) # list[str]
else:
reveal_type(values) # list[object] — NOT narrowed
TypeIs is two-way. PEP 742 requires the guarded type to be consistent with (a subtype of) the
parameter type, and in return the checker narrows both branches — to the guarded type in the
if, and to the difference of the input and guarded types in the else.
# Python 3.13+, mypy 1.x / pyright 1.1.x
from typing import TypeIs
def is_str(value: int | str) -> TypeIs[str]:
return isinstance(value, str)
def handle(value: int | str) -> None:
if is_str(value):
reveal_type(value) # str
else:
reveal_type(value) # int — the else branch is narrowed too
Prefer TypeIs whenever the narrowed type is a genuine subtype of the input; it gives the analyzer
strictly more information. Reach for TypeGuard only when the output type is not a subtype of the
input — for example narrowing list[object] to list[str], which TypeIs rejects.
The precise rule is consistency, not strict nominal subtyping: TypeIs[X] is allowed when X is
consistent with the parameter type in the gradual-typing sense, which is why TypeIs[int] on an
object parameter is fine (every int is an object) but TypeIs[list[str]] on a list[object]
parameter is not — list[str] is not assignable to list[object] because list is invariant.
That invariance is exactly why container-refining guards such as “is this a list[str]?” must use
TypeGuard: the output is a sibling, not a subtype. In the else branch, TypeIs computes the set
difference of the input and the guarded type, so int | str | None minus str leaves int | None;
TypeGuard leaves the full int | str | None because it makes no negative promise. Historically
TypeGuard came first (PEP 647, Python 3.10) and TypeIs was added later (PEP 742, Python 3.13)
precisely to fix the surprising, sound-but-limited one-way behaviour, so on a modern toolchain
TypeIs is the default and TypeGuard the deliberate exception. Always confirm the branch types
with reveal_type in both branches when you are unsure which form a call is using.
Analyzer behaviour
Both checkers implement PEP 647 and PEP 742, but they report violations under different identifiers and diverge on a few narrowing constructs. The matrix below summarises where they line up and where they part ways; the full catalogue lives in mypy vs pyright on TypeGuard.
mypy
mypy implements both PEP 647 and PEP 742. If you annotate TypeIs[X] where X is not consistent
with the parameter type, mypy reports [narrowed-type-not-subtype] — the guarded type must be
narrower than the input. mypy also requires the function to accept a positional argument; a guard
with only keyword-only parameters is rejected with [valid-type]/[misc]. Because these are named
error codes, you can suppress a single one inline with # type: ignore[narrowed-type-not-subtype]
or scope it per module (see below), and warn_unused_ignores will later flag the comment if the
underlying error is fixed. mypy gained TypeGuard in 0.900-era releases and TypeIs in 1.10; pin
mypy >= 1.10 in CI if you rely on TypeIs, since older versions either lack it or narrow generic
guards less precisely.
# Python 3.13+, mypy 1.x
from typing import TypeIs
def bad_guard(value: int) -> TypeIs[str]: # mypy error: [narrowed-type-not-subtype]
return False # str is not consistent with int
pyright
pyright supports TypeGuard and TypeIs and emits reportGeneralTypeIssues when a TypeIs
guarded type is not assignable to the input type. Suppressing that requires the report-category
identifier — # pyright: ignore[reportGeneralTypeIssues] — not mypy’s error code, which is the most
common cross-checker suppression mistake. pyright is generally stricter about generic guards: it
tends to retain a more precise bound type where mypy falls back to the declared input, and it is
quicker to mark code after an impossible assert-guard as unreachable. It also narrows on
hasattr(x, "attr") by synthesising a Protocol that carries the attribute, whereas mypy does not
narrow on hasattr at all — a divergence worth knowing when a guard-like hasattr check “works” in
one checker only. The full set of divergences — especially around the negative branch and generic
guards — is catalogued in
mypy vs pyright on TypeGuard.
Strictness tuning
You rarely need to relax guard checks, but during migration you can scope the relevant error code per module rather than weakening the whole mypy strict config:
# pyproject.toml — temporarily allow an unsound legacy guard while it is rewritten
[[tool.mypy.overrides]]
module = "legacy.validation"
disable_error_code = ["narrowed-type-not-subtype"]
The better fix is almost always to switch an unsound TypeIs to a TypeGuard, or to correct the
guarded type, rather than silencing the diagnostic. A per-module override is blunt — it disables the
check for the whole module — so prefer a single inline # type: ignore[narrowed-type-not-subtype]
on the offending function when only one guard is at fault, and pair it with warn_unused_ignores
so the suppression is removed automatically once you rewrite the guard. pyright has no equivalent
“loosen this one narrowing rule” knob; you either drop the whole file to # pyright: basic or add
# pyright: ignore[reportGeneralTypeIssues] at the call. Because both suppressions hide a genuine
soundness hole — the checker will trust an incorrect guard — treat them strictly as migration
scaffolding with a tracking issue, never as a settled configuration.
Debugging false positives
If a guard “isn’t narrowing”, check three things. First, the value must be passed as the first
positional argument — narrowing a value passed by keyword does not work. Second, narrowing applies
to the expression, so if is_str(obj.field): narrows obj.field only while it stays a simple
attribute path. Third, reassigning the variable inside the branch discards the narrowed type.
# Python 3.13+, mypy 1.x / pyright 1.1.x
def process(value: int | str) -> None:
if is_str(value):
value = compute() # reassignment widens value again
reveal_type(value) # type of compute(), not str
Attribute narrowing is especially fragile. if is_str(obj.field): narrows obj.field, but only
while obj and field are literally re-read as the same attribute path; call a method or index in
between (obj.reload(), items[i]) and the checker conservatively drops the narrowing because it
cannot prove the attribute is unchanged. Both mypy and pyright also invalidate a narrowed local
after a call only when the variable could be mutated through an alias — for a plain local the
narrowing survives ordinary calls, but for an attribute it does not. The robust pattern is to bind
the value to a fresh local first (v = obj.field) and guard v, or to use the walrus operator
(if is_str(v := obj.field):), both of which give the checker a stable name to narrow. When in
doubt, drop a reveal_type immediately after the if to see the actual inferred type rather than
guessing.
Common pitfalls
Four mistakes account for most guard bugs. They are grouped below by whether the checker catches them for you or trusts you to get them right.
- Writing an unsound
TypeGuard: The checker trusts your guard. Ifis_str_listreturnsTruefor a list containing anint, downstream code treats it aslist[str]and crashes at runtime — no error is reported by mypy or pyright. The guard body is your responsibility, so keep it total and side-effect free. - Using
TypeGuardwhereTypeIsfits: You lose negative-branch narrowing for free. If the output is a subtype of the input, useTypeIsand theelsebranch narrows to the complement automatically. Only keepTypeGuardwhen the output is not a subtype (e.g.list[object]tolist[str]). - Forgetting the positional argument: A guard whose narrowed value is keyword-only is rejected
with
[misc]in mypy and effectively ignored by pyright (it never narrows). The value being narrowed must be the first positional parameter. - Expecting
TypeGuardto narrowself: A guard narrows its first positional parameter; on a method that isself, which is rarely what you want. Use a free function, or a@staticmethodtaking the value explicitly, so the narrowed argument is the value rather than the receiver.
FAQ
Is the difference between TypeGuard and TypeIs only the else branch?
That is the visible effect, but the cause is the subtype requirement: TypeIs[X] requires X to be
consistent with the input type, which lets the checker also narrow the negative branch. TypeGuard
has no such requirement, so it can only narrow the positive branch.
Do these forms do anything at runtime?
No. At runtime both functions just return a bool. TypeGuard and TypeIs are erased; only the
static analyzer reads them. A wrong guard body fails silently at runtime.
Which should I use on Python 3.11?
TypeGuard (available since 3.10). TypeIs needs 3.13, or import it from typing_extensions on
older versions.