TypeGuard, TypeIs & Type Narrowing in Python

Type narrowing is how a static analyzer refines a wide type like object or str | bytes into a more specific one inside a branch of code. Python ships built-in narrowing for isinstance, assert, and is None checks, and since PEP 647 it lets you teach the checker your own narrowing rules with typing.TypeGuard (Python 3.10+) and, since PEP 742, the sounder typing.TypeIs (Python 3.13+). This guide covers both special return forms, how mypy and pyright consume them, the one-way vs two-way distinction, and the error codes you will see when a guard is written incorrectly. It builds on the generic machinery in Advanced Typing Patterns & Generics and pairs naturally with Protocol and structural subtyping.

TypeGuard narrowing flow An object value passes through a is_payload check and is narrowed to Payload in the true branch while staying object in the false branch. data: object if is_payload(data): -> TypeGuard[Payload] True data: Payload False data: object
A TypeGuard narrows only the true branch; the false branch keeps the original type.

Built-in narrowing the checker already understands

Before reaching for a custom guard, remember that analyzers narrow automatically on a fixed set of constructs. An isinstance() test, an is None / is not None comparison, an equality check against a Literal, and an assert statement all refine the type in the branches that follow.

# Python 3.11+, mypy 1.x / pyright 1.1.x
def render(value: str | None) -> str:
    if value is None:
        return "(empty)"
    # value is now narrowed to str — no [union-attr] here
    return value.upper()

def take_int(value: object) -> int:
    assert isinstance(value, int)  # narrows the rest of the function to int
    return value + 1

These work because the checker has hard-coded rules for isinstance, is, and assert. The problem appears when the test is hidden behind a helper such as is_valid(value) — the analyzer cannot see inside the call and will not narrow. That is exactly the gap TypeGuard and TypeIs close.

Syntax spec: TypeGuard and TypeIs return forms

A narrowing function returns bool at runtime, but its annotation is a special form. With TypeGuard[X], a True return tells the checker the first positional argument is an X. With TypeIs[X], a True return means the argument is an X and a False return means it is not.

# Python 3.13+, mypy 1.x / pyright 1.1.x
from typing import TypeGuard, TypeIs

def is_str_list(values: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(v, str) for v in values)

def is_str(value: object) -> TypeIs[str]:
    return isinstance(value, str)

On Python 3.9–3.12 both forms are importable from typing_extensions (TypeGuard is also in typing from 3.10; TypeIs from 3.13). The function must take at least one positional parameter — the value being narrowed — and the guarded type goes inside the subscript.

TypeGuard vs TypeIs: one-way vs two-way narrowing

This is the distinction that matters most. TypeGuard is one-way: it narrows only the if branch. The else branch keeps the original declared type, because a TypeGuard[list[str]] does not promise that a False result means “not a list[str]” — the guarded type need not even be a subtype of the input type.

# Python 3.11+, 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)

def handle(values: list[object]) -> None:
    if is_str_list(values):
        reveal_type(values)  # list[str]
    else:
        reveal_type(values)  # list[object] — NOT narrowed

TypeIs is two-way. PEP 742 requires the guarded type to be consistent with (a subtype of) the parameter type, and in return the checker narrows both branches — to the guarded type in the if, and to the difference of the input and guarded types in the else.

# Python 3.13+, mypy 1.x / pyright 1.1.x
from typing import TypeIs

def is_str(value: int | str) -> TypeIs[str]:
    return isinstance(value, str)

def handle(value: int | str) -> None:
    if is_str(value):
        reveal_type(value)  # str
    else:
        reveal_type(value)  # int — the else branch is narrowed too

Prefer TypeIs whenever the narrowed type is a genuine subtype of the input; it gives the analyzer strictly more information. Reach for TypeGuard only when the output type is not a subtype of the input — for example narrowing list[object] to list[str], which TypeIs rejects.

Analyzer behaviour

mypy

mypy implements both PEP 647 and PEP 742. If you annotate TypeIs[X] where X is not consistent with the parameter type, mypy reports [narrowed-type-not-subtype] — the guarded type must be narrower than the input. mypy also requires the function to accept a positional argument; a guard with only keyword-only parameters is rejected with [valid-type]/[misc].

# Python 3.13+, mypy 1.x
from typing import TypeIs

def bad_guard(value: int) -> TypeIs[str]:  # mypy error: [narrowed-type-not-subtype]
    return False                            # str is not consistent with int

pyright

pyright supports TypeGuard and TypeIs and emits reportGeneralTypeIssues when a TypeIs guarded type is not assignable to the input type. pyright additionally offers a strict TypeGuard mode controlled by whether the guarded type is a subtype of the parameter; the divergences between the two checkers — especially around the negative branch and generic guards — are catalogued in mypy vs pyright on TypeGuard.

Strictness tuning

You rarely need to relax guard checks, but during migration you can scope the relevant error code per module rather than weakening the whole mypy strict config:

# pyproject.toml — temporarily allow an unsound legacy guard while it is rewritten
[[tool.mypy.overrides]]
module = "legacy.validation"
disable_error_code = ["narrowed-type-not-subtype"]

The better fix is almost always to switch an unsound TypeIs to a TypeGuard, or to correct the guarded type, rather than silencing the diagnostic.

Debugging false positives

If a guard “isn’t narrowing”, check three things. First, the value must be passed as the first positional argument — narrowing a value passed by keyword does not work. Second, narrowing applies to the expression, so if is_str(obj.field): narrows obj.field only while it stays a simple attribute path. Third, reassigning the variable inside the branch discards the narrowed type.

# Python 3.13+, mypy 1.x / pyright 1.1.x
def process(value: int | str) -> None:
    if is_str(value):
        value = compute()      # reassignment widens value again
        reveal_type(value)     # type of compute(), not str

Common pitfalls

  • Writing an unsound TypeGuard: The checker trusts your guard. If is_str_list returns True for a list containing an int, downstream code treats it as list[str] and crashes at runtime — no error is reported. The guard body is your responsibility.
  • Using TypeGuard where TypeIs fits: You lose negative-branch narrowing for free. If the output is a subtype of the input, use TypeIs.
  • Forgetting the positional argument: A guard whose narrowed value is keyword-only is rejected with [misc] in mypy and ignored by pyright.
  • Expecting TypeGuard to narrow self: A guard narrows its first positional parameter; on a method that is self, which is rarely what you want. Use a free function instead.

FAQ

Is the difference between TypeGuard and TypeIs only the else branch? That is the visible effect, but the cause is the subtype requirement: TypeIs[X] requires X to be consistent with the input type, which lets the checker also narrow the negative branch. TypeGuard has no such requirement, so it can only narrow the positive branch.

Do these forms do anything at runtime? No. At runtime both functions just return a bool. TypeGuard and TypeIs are erased; only the static analyzer reads them. A wrong guard body fails silently at runtime.

Which should I use on Python 3.11? TypeGuard (available since 3.10). TypeIs needs 3.13, or import it from typing_extensions on older versions.

Back to Advanced Typing Patterns & Generics