Python TypeGuard: Custom Type Narrowing Guide
Python's typing.TypeGuard allows developers to write
custom functions that conditionally refine and narrow variable types
within static type checkers like Mypy and Pyright. Introduced in Python
3.10 via PEP 647, TypeGuard solves the limitation where
standard type narrowing only works with inline checks like
isinstance() or issubclass(). By annotating a
function's return type with TypeGuard[T], you instruct the
type checker to treat the checked argument as type T
whenever the function evaluates to True.
The Need for Custom Type Narrowing
Static type checkers routinely perform type narrowing using built-in control flow structures:
def process_data(value: str | int):
if isinstance(value, str):
# The type checker knows value is str here
print(value.upper())However, this automatic narrowing breaks down when logic is
encapsulated into helper functions or involves structural validation,
such as checking elements in a collection or validating nested
dictionaries. A standard helper returning bool does not
convey any type information back to the caller's scope:
def is_string_list(val: list[object]) -> bool:
return all(isinstance(item, str) for item in val)
def handle_items(items: list[object]):
if is_string_list(items):
# Type checkers still see items as list[object], not list[str]
passImplementing TypeGuard
TypeGuard bridges this gap. It replaces
bool as the return annotation, accepting a single type
argument representing the narrowed type.
from typing import TypeGuard
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(item, str) for item in val)
def handle_items(items: list[object]):
if is_string_list(items):
# items is narrowed to list[str] inside this block
print(", ".join(items))
else:
# items remains list[object]
passAt runtime, is_string_list returns a standard boolean.
Statically, the type checker applies the following rules:
- Conditional Narrowing: In any conditional branch
where
is_string_list(items)is truthy, the type checker updates the type ofitemstolist[str]. - First Argument Association: By default,
TypeGuardnarrows the first positional argument passed to the guard function. For methods, it targets the first argument afterselforcls.
Validating Complex Structures
TypeGuard is particularly effective for validating
untyped payloads, such as JSON data or dictionaries, into
TypedDict models:
from typing import TypedDict, TypeGuard, Any
class UserPayload(TypedDict):
id: int
username: str
def is_user_payload(data: dict[str, Any]) -> TypeGuard[UserPayload]:
return (
isinstance(data.get("id"), int) and
isinstance(data.get("username"), str)
)
def handle_request(raw_data: dict[str, Any]):
if is_user_payload(raw_data):
# raw_data is safely typed as UserPayload
print(f"User ID: {raw_data['id']}")Key Considerations
- Developer Responsibility: Type checkers do not
verify that the implementation inside the
TypeGuardfunction actually guarantees the target type. If your runtime logic is flawed, the type checker will still accept the assertion as true, potentially leading to runtime errors. - Negative Narrowing Limitation: A
TypeGuardonly narrows the type in the positive (True) branch. In theelsebranch, the type is generally not narrowed down because failing aTypeGuarddoes not necessarily mean the inverse is true (an aspect addressed in Python 3.13 withtyping.TypeIs).