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.

Built-in narrowing constructs and their results isinstance, is None, Literal equality and assert are constructs the checker understands natively, each mapping to a narrowed type in the branch that follows. Test the checker recognises Narrowed in that branch isinstance(x, int) x: int x is None (else branch) x: str (None removed) x["kind"] == "cat" x: Cat (tagged union) assert isinstance(x, T) x: T (rest of scope)
Each construct the checker hard-codes refines the variable's type in the branch it controls; assert narrows the whole remaining scope.
# 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

The built-in set is larger than most code uses. type(x) is Circle narrows to exactly Circle, unlike isinstance, which also admits subclasses; callable(x) narrows to a callable type; and truthiness (if x:) strips None from an Optional[str]. The most valuable case is comparing a Literal discriminant, which narrows a tagged union — the standard way to resolve a union of TypedDicts:

# Python 3.11+, mypy 1.x / pyright 1.1.x
from typing import Literal, TypedDict

class Cat(TypedDict):
    kind: Literal["cat"]
    lives: int

class Dog(TypedDict):
    kind: Literal["dog"]
    good: bool

def describe(animal: Cat | Dog) -> str:
    if animal["kind"] == "cat":
        return f"{animal['lives']} lives"          # narrowed to Cat
    return "good boy" if animal["good"] else "bad"  # narrowed to Dog

Structural pattern matching (match/case, PEP 634, Python 3.10+) narrows on class and value patterns as well, so a case int(): block sees an int. The two checkers diverge on a couple of constructs: pyright narrows hasattr(x, "foo") to a synthesised Protocol carrying that attribute, whereas mypy does not narrow on hasattr at all. Both narrow is None, isinstance, assert, and Literal equality identically.

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; it only sees a bool come back. Narrowing is also flow-sensitive: it is discarded the moment you reassign the variable, and it does not survive a function-call boundary, since the checker cannot assume value is unchanged after an arbitrary call. That is exactly the gap TypeGuard and TypeIs close — they let a helper return narrowing information to its call site.

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.

Anatomy of a TypeIs narrowing signature The signature def is_str(value: object) -> TypeIs[str] is split into its parts: the first positional argument is the value narrowed, TypeIs is the special return form, and str is the guarded type; the body returns a plain bool. runtime: the body just returns a bool def is_str( value: object ) -> TypeIs [str] first positional argument the value being narrowed special return form True narrows the arg guarded type X inside the subscript
The narrowed value is the first positional parameter, TypeIs/TypeGuard is the special return form, and the guarded type sits in the subscript.
# 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.

The narrowed value is always the first positional parameter, but a guard may take more parameters after it; they are ordinary arguments and play no part in narrowing. A method can be a guard too — its first positional parameter is self, so a method guard narrows the receiver, which is seldom what you want (prefer a free function). The guarded type in the subscript can be any type form: a concrete class (TypeIs[int]), a generic alias (TypeGuard[list[str]]), a union (TypeIs[int | float]), or a parameterised type using a TypeVar. Because the annotation is a special form rather than a runtime value, from __future__ import annotations (PEP 563) does not change how it is interpreted — it only defers evaluation of the string. At runtime the function is a completely normal predicate: it must actually return a truthy or falsy value, and the special form is erased, so a guard whose body forgets to return yields None (falsy) and silently narrows nothing. Both mypy and pyright also require the return type to be exactly TypeGuard[...] or TypeIs[...]; wrapping it, as in -> bool | TypeIs[str], is rejected as an invalid type.

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.

One-way TypeGuard versus two-way TypeIs branch narrowing TypeGuard narrows only the if branch and leaves the else branch unchanged, whereas TypeIs narrows both the if and the else branch. TypeGuard — one-way values: list[object] True False if -> list[str] narrowed else -> list[object] unchanged TypeIs — two-way value: int | str True False if -> str narrowed else -> int narrowed too
TypeGuard refines only the if branch; TypeIs also narrows the else branch to the complement of the guarded 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.

The precise rule is consistency, not strict nominal subtyping: TypeIs[X] is allowed when X is consistent with the parameter type in the gradual-typing sense, which is why TypeIs[int] on an object parameter is fine (every int is an object) but TypeIs[list[str]] on a list[object] parameter is not — list[str] is not assignable to list[object] because list is invariant. That invariance is exactly why container-refining guards such as “is this a list[str]?” must use TypeGuard: the output is a sibling, not a subtype. In the else branch, TypeIs computes the set difference of the input and the guarded type, so int | str | None minus str leaves int | None; TypeGuard leaves the full int | str | None because it makes no negative promise. Historically TypeGuard came first (PEP 647, Python 3.10) and TypeIs was added later (PEP 742, Python 3.13) precisely to fix the surprising, sound-but-limited one-way behaviour, so on a modern toolchain TypeIs is the default and TypeGuard the deliberate exception. Always confirm the branch types with reveal_type in both branches when you are unsure which form a call is using.

Analyzer behaviour

Both checkers implement PEP 647 and PEP 742, but they report violations under different identifiers and diverge on a few narrowing constructs. The matrix below summarises where they line up and where they part ways; the full catalogue lives in mypy vs pyright on TypeGuard.

mypy versus pyright guard-handling matrix Across unsound TypeIs, keyword-only values, diagnostic style, PEP support and hasattr narrowing, mypy uses named error codes while pyright uses report categories. behaviour mypy pyright unsound TypeIs [narrowed-type-not-subtype] reportGeneralTypeIssues keyword-only value [valid-type] / [misc] ignored diagnostic style named error codes report categories PEP 647 / 742 both both hasattr narrowing not narrowed synth Protocol
Same PEP semantics, different diagnostics: mypy emits suppressible error codes, pyright emits report categories.

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]. Because these are named error codes, you can suppress a single one inline with # type: ignore[narrowed-type-not-subtype] or scope it per module (see below), and warn_unused_ignores will later flag the comment if the underlying error is fixed. mypy gained TypeGuard in 0.900-era releases and TypeIs in 1.10; pin mypy >= 1.10 in CI if you rely on TypeIs, since older versions either lack it or narrow generic guards less precisely.

# 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. Suppressing that requires the report-category identifier — # pyright: ignore[reportGeneralTypeIssues] — not mypy’s error code, which is the most common cross-checker suppression mistake. pyright is generally stricter about generic guards: it tends to retain a more precise bound type where mypy falls back to the declared input, and it is quicker to mark code after an impossible assert-guard as unreachable. It also narrows on hasattr(x, "attr") by synthesising a Protocol that carries the attribute, whereas mypy does not narrow on hasattr at all — a divergence worth knowing when a guard-like hasattr check “works” in one checker only. The full set of divergences — especially around the negative branch and generic guards — is 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:

Handling a flagged guard: suppress temporarily or fix properly A flagged guard leads to two options — a temporary per-module suppression that leaves it unsound, or the preferred fix of switching TypeIs to TypeGuard or correcting the type — with the temporary path eventually feeding into the fix. guard flagged narrowed-type-not-subtype temporary: per-module disable_error_code unblocks CI, still unsound at runtime preferred: TypeIs -> TypeGuard, or fix the type sound; diagnostic gone for good then rewrite
Silence the diagnostic per module only as a bridge; the durable fix is to switch to TypeGuard or correct the guarded type.
# 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. A per-module override is blunt — it disables the check for the whole module — so prefer a single inline # type: ignore[narrowed-type-not-subtype] on the offending function when only one guard is at fault, and pair it with warn_unused_ignores so the suppression is removed automatically once you rewrite the guard. pyright has no equivalent “loosen this one narrowing rule” knob; you either drop the whole file to # pyright: basic or add # pyright: ignore[reportGeneralTypeIssues] at the call. Because both suppressions hide a genuine soundness hole — the checker will trust an incorrect guard — treat them strictly as migration scaffolding with a tracking issue, never as a settled configuration.

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.

Three checks when a guard fails to narrow Confirm the value is the first positional argument, the target is a simple attribute path, and the variable is not reassigned inside the branch. 1 Passed as the first positional argument? a value passed by keyword is never narrowed 2 Narrowing target is a simple attribute path? obj.field holds; a call or index between checks resets it 3 Variable untouched inside the branch? reassignment discards the narrowed type
Three flow-sensitivity checks: first-positional, simple attribute path, and no reassignment.
# 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

Attribute narrowing is especially fragile. if is_str(obj.field): narrows obj.field, but only while obj and field are literally re-read as the same attribute path; call a method or index in between (obj.reload(), items[i]) and the checker conservatively drops the narrowing because it cannot prove the attribute is unchanged. Both mypy and pyright also invalidate a narrowed local after a call only when the variable could be mutated through an alias — for a plain local the narrowing survives ordinary calls, but for an attribute it does not. The robust pattern is to bind the value to a fresh local first (v = obj.field) and guard v, or to use the walrus operator (if is_str(v := obj.field):), both of which give the checker a stable name to narrow. When in doubt, drop a reveal_type immediately after the if to see the actual inferred type rather than guessing.

Common pitfalls

Four mistakes account for most guard bugs. They are grouped below by whether the checker catches them for you or trusts you to get them right.

Four common guard pitfalls An unsound guard body, using TypeGuard where TypeIs fits, a keyword-only narrowed value, and narrowing self on a method guard. unsound guard body checker trusts it — silent crash no diagnostic at all TypeGuard where TypeIs fits loses free else-branch narrowing use TypeIs for a subtype keyword-only narrowed value mypy [misc] / pyright ignores value must be first positional narrowing self on a method first positional is self use a free function instead
Top row: mistakes the checker cannot catch (soundness and design); bottom row: mistakes the checker flags or silently ignores.
  • 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 by mypy or pyright. The guard body is your responsibility, so keep it total and side-effect free.
  • Using TypeGuard where TypeIs fits: You lose negative-branch narrowing for free. If the output is a subtype of the input, use TypeIs and the else branch narrows to the complement automatically. Only keep TypeGuard when the output is not a subtype (e.g. list[object] to list[str]).
  • Forgetting the positional argument: A guard whose narrowed value is keyword-only is rejected with [misc] in mypy and effectively ignored by pyright (it never narrows). The value being narrowed must be the first positional parameter.
  • 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, or a @staticmethod taking the value explicitly, so the narrowed argument is the value rather than the receiver.

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