mypy vs pyright on TypeGuard and TypeIs

TL;DR

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.

mypy vs pyright narrowing comparison Both checkers narrow the true branch identically, but report different diagnostics for an unsound TypeIs guard. mypy 1.x if branch -> narrowed else branch -> TypeIs only [narrowed-type-not-subtype] named error codes pyright 1.1.x if branch -> narrowed else branch -> TypeIs only reportGeneralTypeIssues prose report messages
Identical narrowing semantics, different diagnostics and stricter generic handling in pyright.

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 TypeGuard narrows the else branch on one checker: Neither does. If you need the negative branch, switch to TypeIs, and confirm with reveal_type on 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: TypeIs results on mypy < 1.10 differ from pyright; pin both, or you will chase phantom narrowing bugs.
Runtime vs static analysis Neither checker validates that your guard body is *correct* at runtime. `is_str(value)` returning a truthy value for a non-`str` will satisfy both mypy and pyright while crashing in production. The divergences here are purely about static diagnostics; both tools equally trust your implementation.

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.

Back to TypeGuard & Type Narrowing