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.
TypeGuard narrows only the true branch; the false branch keeps the original type.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
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. That is exactly the gap TypeGuard and TypeIs
close.
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.
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.
Analyzer behaviour
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].
# 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. pyright additionally offers a strict TypeGuard
mode controlled by whether the guarded type is a subtype of the parameter; the divergences between
the two checkers — especially around the negative branch and generic guards — are 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.
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
Common pitfalls
- 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. The guard body is your responsibility. - Using
TypeGuardwhereTypeIsfits: You lose negative-branch narrowing for free. If the output is a subtype of the input, useTypeIs. - Forgetting the positional argument: A guard whose narrowed value is keyword-only is rejected
with
[misc]in mypy and ignored by pyright. - 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 instead.
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.