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.
TypeIs when the narrowed type is a subtype of the input, otherwise TypeGuard.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. 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), so this case must
use TypeGuard, not TypeIs.
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.
# 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. Be exhaustive.
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.
# 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")
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.
# 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.
Edge cases
- Extra parameters: A guard may take more than one argument (
is_instance_of(value, cls)); only the first positional parameter is narrowed. Additional parameters are passed normally. - Generic guards:
def is_list_of(values: list[object], ...) -> TypeGuard[list[T]]can thread a TypeVar, but inference varies between checkers — verify withreveal_type. - Method guards narrow
self: Defined on a class, the first positional parameter isself, so the guard narrows the receiver, not an argument. Use a free function instead.
Common mistakes
- 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. - Using
TypeIswith a non-subtype narrowed type: mypy reports[narrowed-type-not-subtype]and pyright reportsreportGeneralTypeIssues. Switch toTypeGuardfor non-subtype targets. - Narrowing a value passed by keyword:
is_str_list(values=data)does not narrow. Pass the value positionally.
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.