Writing Custom Type-Narrowing Functions
Write a function that returns bool at runtime but annotate its return as TypeGuard[X] (one-way
narrowing, PEP 647) or TypeIs[X] (two-way, PEP 742). Use TypeIs when X is a subtype of the
input type; use TypeGuard when it is not (e.g. list[object] to list[str]). The checker trusts
your body completely, so the narrowing must be sound or you ship a silent runtime bug.
When validation logic lives behind a helper — is_valid_config(data), is_str_list(values) — a
static analyzer cannot see inside the call and will not narrow the type afterward. PEP 647 (Python
3.10) and PEP 742 (Python 3.13) let you annotate that helper so the checker narrows on your behalf.
This guide walks through writing such a function step by step, choosing between TypeGuard and
TypeIs, and avoiding the soundness traps. For the conceptual background, see the
TypeGuard and TypeIs overview.
Step 1: identify the input and narrowed types
A narrowing function maps one type (the parameter) to a narrower one (the subscript). Decide both
before writing the body, because that pair determines which decorator is even legal. The input is
the declared type of the first positional parameter; the narrowed type is whatever you put inside
TypeGuard[...] or TypeIs[...]. Here the input is list[object] and we want list[str].
# Python 3.10+, mypy 1.x / pyright 1.1.x
from typing import TypeGuard
def is_str_list(values: list[object]) -> TypeGuard[list[str]]:
... # the checker sees: True means `values` is list[str] in the if branch
The narrowed type goes in the subscript; the parameter being narrowed is the first positional
argument. list[str] is not a subtype of list[object] (list is invariant — see
why list is invariant),
so this case must use TypeGuard, not TypeIs. Contrast that with a str | None input narrowed to
str: because str genuinely is a member of that union, containment holds and TypeIs becomes
the better choice. The mechanical test is a subtype (assignability) check — “is every value of the
narrowed type also a value of the input type?” When the answer is yes, both decorators are legal and
TypeIs is preferred; when it is no, TypeIs is rejected at definition time and TypeGuard is your
only option.
Note that TypeGuard/TypeIs are legal only in the return position, never as a parameter or
variable annotation. x: TypeGuard[int] is a type error ([valid-type] in mypy,
reportInvalidTypeForm in pyright): they are not real types, they are annotations that reshape
control-flow analysis. On Python 3.10–3.12 TypeGuard lives in typing, while TypeIs needs
Python 3.13; both are available on every version through typing_extensions, which is the portable
import for libraries that still support 3.8. If you use
from __future__ import annotations,
the annotation becomes a string and the decorator still works, because the checker reads the source
text rather than the runtime object — but typing.get_type_hints() will try to resolve
TypeGuard[...], so keep the import present even under stringized annotations.
Step 2: write a sound body
The body must return True exactly when the value really is the narrowed type. The checker does not
verify this — it trusts you — so a loose check is a latent bug that the type system will actively
hide from you. This is the single most important rule of custom guards: an unsound body is worse
than no guard, because it launders a runtime type error into code the analyzer has already blessed.
# Python 3.10+, 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) # checks EVERY element
A common unsound shortcut is checking only the first element (isinstance(values[0], str)). That
satisfies the type checker but lies about the rest of the list, and it also raises IndexError on
an empty list — all(...) over an empty iterable correctly returns True, so the exhaustive form
handles that edge cleanly. Be exhaustive. Neither mypy nor pyright will warn you about the partial
check; there is no [return]-style diagnostic that inspects whether your predicate actually implies
the narrowed type. The soundness obligation is entirely on you.
A subtler unsoundness comes from widening inside the body. If you narrow to list[str] but the
body does return all(isinstance(v, (str, bytes)) for v in values), the checker still narrows to
list[str] even though a bytes slips through — the body’s runtime logic and the annotation have
silently diverged. Guard bodies should mirror their subscript as closely as possible: one
isinstance per element type, no broader. For TypeIs, the stakes double, because the else branch
is trusted too (see TypeIs vs TypeGuard):
a body that returns False for a value that is the narrowed type will corrupt the negative branch.
Keep the predicate total — every input should get a correct True/False, with no path that
returns the wrong answer for a value on the boundary.
Step 3: use the guard and observe the narrowing
At the call site, the value must be passed as the first positional argument and used directly in the
condition. The narrowing is a control-flow effect: it applies only inside the branch that the guard’s
truthiness selects, and only to the exact expression you passed. Assigning the value to another name
first, or wrapping the call in bool(...), can defeat the analysis in some checker versions, so keep
the call shape simple.
# Python 3.10+, mypy 1.x / pyright 1.1.x
def join_lines(values: list[object]) -> str:
if is_str_list(values):
return "\n".join(values) # values: list[str] — no [arg-type] on join
raise TypeError("expected a list of strings")
Inside the if, reveal_type(values) prints list[str] under both checkers; after the block, on
the fall-through path, it prints list[object] again because TypeGuard never touches the negative
branch. The narrowing is also scoped — it lasts only until the value is reassigned or the branch
ends. A while is_str_list(values): loop narrows the body just like an if, and combining a guard
with and narrows the right-hand operand (if values and is_str_list(values):). What does not
work is narrowing through a boolean stored in a variable: ok = is_str_list(values); if ok: leaves
values at list[object], because the checker has lost the syntactic link between the condition and
the argument. Keep the guard call in the condition itself.
Step 4: prefer TypeIs when the result is a subtype
For a is_valid_config helper that narrows a TypedDict
or a str | int union, the narrowed type is a subtype of the input — so use TypeIs and get
negative-branch narrowing for free. TypeIs (PEP 742, Python 3.13) behaves like a user-defined
isinstance: the if branch narrows to the guarded type and the else branch narrows to “input
minus the guarded type.” That two-way behaviour is only sound when the guarded type is assignable to
the input, which is exactly why TypeIs enforces the subtype rule at definition time.
# Python 3.13+, mypy 1.x / pyright 1.1.x
from typing import TypeIs
def is_nonempty(value: str | None) -> TypeIs[str]:
return isinstance(value, str) and value != ""
def slug(value: str | None) -> str:
if is_nonempty(value):
return value.lower() # value: str
return "untitled" # value: str | None here (else not narrowed to None,
# because "" is a str that returns False)
That last comment is the crucial subtlety: because is_nonempty returns False for the empty
string (still a str), the else branch is str | None, not just None. TypeIs narrows the
negative branch to “input minus the guarded type” only when the guarded type cleanly partitions the
input. Here the body’s value != "" check makes the predicate return False for some str values,
so the guard is not a clean partition on str, and the checker cannot subtract str from the
else. This is not a bug — it is the analyzer being conservative: it narrows the else to None
only when passing the if is equivalent to being a str. If you want the empty string excluded
from the type as well, you need a genuine subtype (there is no “non-empty str” type in Python), so
the runtime check and the static type simply cannot line up, and str | None in the else is the
honest answer.
Declaring TypeIs with a non-subtype target fails immediately: def bad(x: int) -> TypeIs[str]
draws error: Narrowed type "str" is not a subtype of input type "int" [narrowed-type-not-subtype]
from mypy and reportGeneralTypeIssues from pyright. There is one wrinkle worth knowing: the
subtype check is against the declared parameter type, so widening the parameter to object makes
almost any TypeIs[X] legal again — def is_str(x: object) -> TypeIs[str] is fine because str is
a subtype of object. Whenever both decorators are legal, reach for TypeIs; it is strictly more
informative and reads as an isinstance-like predicate to anyone maintaining the code.
Edge cases
A few call-shape and definition rules trip people up, and they behave identically under both PEP 647
and PEP 742. The governing principle is that narrowing always targets the first positional
parameter and nothing else: extra parameters ride along untouched, and where the first positional
slot is self, it is the receiver that gets narrowed.
# Python 3.10+, mypy 1.x / pyright 1.1.x
from typing import TypeGuard
def is_instance_of(value: object, cls: type) -> TypeGuard[str]:
return isinstance(value, cls) # narrows `value` only; `cls` is a normal arg
def use(x: object) -> None:
if is_instance_of(x, str):
reveal_type(x) # str — cls did not participate in narrowing
- Extra parameters: A guard may take more than one argument; only the first positional
parameter is narrowed. Additional parameters are passed normally and never influence which type
the first one is narrowed to — the subscript is fixed at definition time, so
is_instance_of(x, int)still narrowsxtostrif the annotation saysTypeGuard[str]. That mismatch is a smell; a truly polymorphic “is instance ofcls” guard cannot be expressed with a static subscript. - Generic guards:
def is_list_of(values: list[object]) -> TypeGuard[list[T]]can thread a TypeVar, but theTmust appear somewhere the checker can solve it — usually a second parameter liketyp: type[T]. Inference varies between checkers on these, so verify withreveal_type; pyright generally bindsTfrom thetype[T]argument, while mypy is stricter about whereTis deducible. - Method guards narrow
self: Defined on a class, the first positional parameter isself, so the guard narrows the receiver, not an argument. That is occasionally what you want (narrowingselffrom a base to a subclass), but if you meant to narrow an argument, use a free function. - Async and lambda guards: the guarded function must return
boolsynchronously; anasync defguard returns a coroutine, soif await is_ready(x):does not narrow — theawaitbreaks the syntactic link. Assign the awaited value andisinstance-check it instead.
Common mistakes
Most guard bugs are soundness bugs the checker cannot catch, so they surface late — at write time everything is green, at check time everything is green, and only at runtime does the lie land. The timeline below is the mental model: an unsound guard is trusted all the way through static analysis and detonates in production.
# Python 3.10+, mypy 1.x / pyright 1.1.x — the classic unsound guard
def is_str_list(values: list[object]) -> TypeGuard[list[str]]:
return isinstance(values[0], str) # only element 0 — UNSOUND
data: list[object] = ["ok", 42]
if is_str_list(data):
"\n".join(data) # checker: fine. runtime: TypeError on the int
- Checking only one element / a partial condition: Produces an unsound guard the checker happily
trusts; downstream
[arg-type]-free code crashes at runtime. Validate exhaustively withall(isinstance(v, str) for v in values). - Using
TypeIswith a non-subtype narrowed type: mypy reports[narrowed-type-not-subtype]and pyright reportsreportGeneralTypeIssuesat the definition, not the call site. Switch toTypeGuardfor non-subtype targets such aslist[object]→list[str]. - Narrowing a value passed by keyword:
is_str_list(values=data)does not narrow in current mypy — pass the value positionally. The narrowing analysis keys on the first positional argument. - Storing the result before testing it:
ok = is_str_list(data); if ok:loses the narrowing, because the checker cannot connectokback todata. Call the guard directly in the condition. - Annotating the parameter with
TypeGuard:TypeGuard/TypeIsare legal only as a return type. A parameter or variable annotation draws[valid-type](mypy) /reportInvalidTypeForm(pyright). - Reaching for
TypeIson Python 3.12 or earlier fromtyping: it only landed intypingin 3.13; import it fromtyping_extensionson older runtimes or the import fails at runtime.
FAQ
Can a TypeGuard function also raise instead of returning False?
It can, but the narrowing only applies in the branch guarded by its bool result. If you want
“narrow or raise”, use assert isinstance(...) or a function returning the narrowed value directly.
Should new code default to TypeIs?
Yes, whenever the narrowed type is a subtype of the input. TypeIs is strictly more informative;
keep TypeGuard for cases like list[object] to list[str] where the subtype rule does not hold.