mypy vs pyright on TypeGuard and TypeIs
mypy and pyright agree on the core PEP 647 / PEP 742 rules: TypeGuard narrows only the positive
branch, TypeIs narrows both. They diverge on details — pyright is stricter about generic guards and
emits report-style messages, while mypy uses named error codes like [narrowed-type-not-subtype],
handles the assert-plus-guard interaction slightly differently, and historically lagged pyright on
TypeIs support. Pin mypy ≥ 1.10 and a recent pyright ≥ 1.1.360 for consistent behaviour.
User-defined narrowing arrived in two PEPs: PEP 647 (TypeGuard, Python 3.10) and PEP 742
(TypeIs, Python 3.13). Both mypy and pyright implement both, but because the PEPs leave some
behaviour to the implementation — and because the two checkers have independent narrowing engines —
the same guard can produce different reveal_type output. This guide puts the concrete divergences
side by side, with the versions they were observed on. For the underlying mechanics, start with the
TypeGuard and TypeIs overview.
Versions under test
The behaviour below was observed on mypy 1.10+ and pyright 1.1.360+, both targeting Python
3.13 so that TypeIs is available natively. On earlier toolchains, import TypeIs from
typing_extensions. Pin both in CI; the rest of the
pyright vs mypy comparison
covers configuration parity.
Agreement: the positive branch and TypeIs negative branch
Start with what is identical, so you know what not to worry about. Both checkers narrow the if
branch of a TypeGuard to the guarded type, and both narrow both branches of a TypeIs.
# Python 3.13+, mypy 1.10 AND pyright 1.1.360 — identical results
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) # both: str
else:
reveal_type(value) # both: int
Divergence 1: the unsound-TypeIs diagnostic
PEP 742 requires the guarded type to be consistent with the parameter type. Both checkers reject a violation, but the message differs — which matters when you grep CI logs.
# Python 3.13+
from typing import TypeIs
def bad(value: int) -> TypeIs[str]: # str is not consistent with int
return False
# mypy 1.10: error: Narrowed type "str" is not a subtype of input type "int" [narrowed-type-not-subtype]
# pyright 1.1: error: Return type of TypeIs must be assignable to the first parameter — reportGeneralTypeIssues
mypy gives you a stable, suppressible error code; pyright gives a descriptive message under
reportGeneralTypeIssues. To suppress, you target narrowed-type-not-subtype in mypy versus
reportGeneralTypeIssues in pyright.
Divergence 2: generic guards and inference
pyright tends to retain more precise types when a guard is generic, while mypy is sometimes more
conservative and falls back to the declared input type. Consider a generic TypeIs:
# Python 3.13+
from typing import TypeIs, TypeVar
T = TypeVar("T")
def is_two(value: list[T]) -> TypeIs[tuple[T, T]]: # contrived: illustrates generic binding
return False
def use(items: list[int]) -> None:
if is_two(items):
reveal_type(items)
# pyright: tuple[int, int]
# mypy: tuple[int, int] (1.10+) — earlier mypy widened to list[int]
The practical takeaway: on older mypy you may see the binding lost, so confirm with reveal_type
in your actual toolchain rather than assuming parity. Generic guards over
TypeVars are the most common source of
checker disagreement.
Divergence 3: interaction with assert
Both checkers narrow after assert is_str(value), but they differ on unreachability. After an
assert on a TypeGuard whose positive type is empty in context, pyright is quicker to mark
following code unreachable, while mypy may keep analysing it.
# Python 3.13+
from typing import TypeIs
def is_str(value: int | str) -> TypeIs[str]:
return isinstance(value, str)
def f(value: int) -> None: # note: value is int, not int | str
assert is_str(value) # asserts an impossible narrowing
reveal_type(value)
# pyright: Never (treats the rest as unreachable)
# mypy: str (narrows to the guarded type without proving unreachability)
Divergence 4: narrowing self vs the first positional
A guard narrows its first positional parameter. On a method, that parameter is self. pyright and
mypy both bind the guard to self in that case, but pyright surfaces a clearer warning that the
guard is narrowing the receiver, whereas mypy silently accepts it. Use a free function — both
checkers narrow the passed argument consistently — rather than a method-based guard.
Common mistakes
- Assuming
TypeGuardnarrows theelsebranch on one checker: Neither does. If you need the negative branch, switch toTypeIs, and confirm withreveal_typeon both tools. - Suppressing the wrong identifier: A
# type: ignore[narrowed-type-not-subtype]silences mypy but does nothing for pyright; pyright needs# pyright: ignore[reportGeneralTypeIssues]. - Running mismatched versions in CI:
TypeIsresults on mypy < 1.10 differ from pyright; pin both, or you will chase phantom narrowing bugs.
FAQ
Which checker is “right” when they disagree? Both implement the PEPs; disagreements are usually about inference depth, not correctness. Treat the stricter result (often pyright’s) as the safer assumption and write your guard so both agree.
Can I make the negative-branch behaviour identical?
Yes — use TypeIs rather than TypeGuard whenever the guarded type is a subtype of the input. Both
checkers then narrow the else branch the same way.