Writing Custom Type-Narrowing Functions

TL;DR

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.

Writing a narrowing function Decide the input type, the narrowed type, then choose TypeGuard for non-subtype or TypeIs for subtype results. 1. Input type list[object] 2. Narrowed type list[str] subtype? -> TypeIs[X] not subtype? -> TypeGuard[X]
Choose 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, 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].

Narrowed type as a region inside the input type The input type is a large region; the narrowed type is a smaller region that may sit inside it for TypeIs or overlap only partially for TypeGuard. input: str | None narrowed: str subtype → TypeIs ok input: list[object] list[str] not a subtype
When the narrowed type nests inside the input, TypeIs is legal; when invariance breaks containment, only TypeGuard fits.
# 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.

Unsound partial check versus sound exhaustive check Checking only the first element passes the type checker but lies about later elements, while checking every element is sound. values = ["a", "b", 3] isinstance(values[0], str) only element 0 checked checker: narrowed to list[str] runtime: crash on the int all(isinstance(v, str) ...) every element checked checker: narrowed to list[str] runtime: returns False, safe
Both bodies satisfy the checker, but only the exhaustive one tells the truth at runtime.
# 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.

Control flow after a TypeGuard call The if branch narrows the value to the guarded type while the else branch keeps the original type for a one-way TypeGuard. is_str_list(values) True False if branch values: list[str] narrowed else branch values: list[object] TypeGuard: not narrowed
A TypeGuard narrows only the positive branch; the else keeps the declared input type.
# 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.

Branch narrowing matrix for TypeGuard versus TypeIs Both narrow the if branch to the guarded type, but only TypeIs narrows the else branch to the input minus the guarded type. if branch else branch TypeGuard[str] TypeIs[str] str str | None (unchanged) str None (narrowed)
The only cell that differs is the else branch: TypeIs subtracts the guarded type, TypeGuard leaves it alone.
# 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.

Only the first positional parameter is narrowed In a guard signature the first positional parameter is the one that gets narrowed while later parameters are ordinary arguments. is_instance_of(value, cls) value → narrowed by the guard cls → ordinary argument, untouched
Only the top slot — the first positional parameter — is refined; every later parameter is passed normally.
# 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 narrows x to str if the annotation says TypeGuard[str]. That mismatch is a smell; a truly polymorphic “is instance of cls” 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 the T must appear somewhere the checker can solve it — usually a second parameter like typ: type[T]. Inference varies between checkers on these, so verify with reveal_type; pyright generally binds T from the type[T] argument, while mypy is stricter about where T is deducible.
  • Method guards narrow self: Defined on a class, the first positional parameter is self, so the guard narrows the receiver, not an argument. That is occasionally what you want (narrowing self from 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 bool synchronously; an async def guard returns a coroutine, so if await is_ready(x): does not narrow — the await breaks the syntactic link. Assign the awaited value and isinstance-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.

When an unsound guard fails An unsound guard is accepted at write time and static-check time, and only fails at runtime where the error finally appears. write time looks fine static check no error reported runtime TypeError / crash
An unsound guard passes every static gate and fails only where it hurts — 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 with all(isinstance(v, str) for v in values).
  • Using TypeIs with a non-subtype narrowed type: mypy reports [narrowed-type-not-subtype] and pyright reports reportGeneralTypeIssues at the definition, not the call site. Switch to TypeGuard for non-subtype targets such as list[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 connect ok back to data. Call the guard directly in the condition.
  • Annotating the parameter with TypeGuard: TypeGuard/TypeIs are legal only as a return type. A parameter or variable annotation draws [valid-type] (mypy) / reportInvalidTypeForm (pyright).
  • Reaching for TypeIs on Python 3.12 or earlier from typing: it only landed in typing in 3.13; import it from typing_extensions on 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.

Back to TypeGuard & Type Narrowing