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. 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.

Runtime vs static analysis At runtime your guard is an ordinary function returning a `bool`; `TypeGuard` and `TypeIs` are erased and do nothing. The static checker narrows purely on faith in your annotation. If `is_str_list` returns `True` for a list containing an `int`, the analyzer reports no error and the `"\n".join(values)` call raises `TypeError` only in production.

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 with reveal_type.
  • Method guards narrow self: Defined on a class, the first positional parameter is self, 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 TypeIs with a non-subtype narrowed type: mypy reports [narrowed-type-not-subtype] and pyright reports reportGeneralTypeIssues. Switch to TypeGuard for 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.

Back to TypeGuard & Type Narrowing