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]
        pass

Implementing 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]
        pass

At runtime, is_string_list returns a standard boolean. Statically, the type checker applies the following rules:

  1. Conditional Narrowing: In any conditional branch where is_string_list(items) is truthy, the type checker updates the type of items to list[str].
  2. First Argument Association: By default, TypeGuard narrows the first positional argument passed to the guard function. For methods, it targets the first argument after self or cls.

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