skip to content

How does `typing.TypeIs` narrowing differ from `typing.TypeGuard` in the else branch?

level: middleimportance: must knowfreq 35%

answer

  1. One of them tells you nothing on False
  2. Two-way narrowing costs a constraint
  3. Subtraction happens in the else branch
  4. TypeIs demands an assignable target type
  5. Invariant containers force the one-way form

basics

~20 s

TypeIs narrows both branches: the value is the guarded type in the if branch and has that type subtracted in the else branch. TypeGuard narrows only the if branch and leaves the else branch at the declared type.

solid answer

~40 s

`typing.TypeGuard[T]` is one-way: on a True result the value becomes `T`, and on the False branch it keeps whatever type it was declared with. `typing.TypeIs[T]` (Python 3.13, PEP 742) is two-way: the positive branch intersects the declared type with `T`, and the negative branch subtracts `T`, so a parameter declared `int | str` becomes `str` in the else. The price of that extra power is a constraint — `T` must be assignable to the declared parameter type — while `TypeGuard` allows any unrelated `T`. That is the whole selection rule: use `TypeIs` for genuine is-this-one-of-these tests, which is most of them, and fall back to `TypeGuard` when the conclusion is not a subtype of the input, such as narrowing a `list[object]` to a `list[str]`, where invariance rules `TypeIs` out.

code

python · 16 lines
python
from typing import TypeGuard, TypeIs

def guards_int(v: int | str) -> TypeGuard[int]:
    return isinstance(v, int)

def is_int(v: int | str) -> TypeIs[int]:
    return isinstance(v, int)

def show(v: int | str) -> None:
    if is_int(v):
        print(v + 1)
    else:
        print(v.upper())

show(41)
show("beta-checkout")

go deeper

for a junior

Learn the headline: TypeIs narrows both branches of the if, TypeGuard only the True branch. Knowing which one leaves the else branch unchanged is most of the answer.

for a middle

Explain the trade: two-way narrowing is paid for by requiring the guarded type to be assignable to the parameter type, which is why invariant containers still need TypeGuard.

for a senior

Demonstrate the symmetric obligation TypeIs creates — a False result is now a claim too — and be ready to review predicates that accept only a subset of a type as TypeGuard-only.

for a principal

Set the house rule: TypeIs as the default for subtype tests, TypeGuard reserved for shape conclusions, and a documented review standard for these unverified assertions across a large codebase.

## Two spellings, one visible difference Both `typing.TypeGuard[T]` and `typing.TypeIs[T]` are return annotations for a user-written predicate, and both make a checker narrow the first positional parameter when the call returns True. They differ in what happens on the other branch, and in what signatures they permit. ```python from typing import TypeGuard, TypeIs def guards_int(v: int | str) -> TypeGuard[int]: return isinstance(v, int) def is_int(v: int | str) -> TypeIs[int]: return isinstance(v, int) ``` With `is_int`, an `else` branch sees `v` as `str` — the checker subtracted `int` from the declared union. With `guards_int`, the `else` branch still sees `int | str`, so `v.upper()` is an error there even though the value cannot possibly be an `int`. That is the answer interviewers are listening for. ## What each one computes, precisely `TypeGuard[T]` replaces the type wholesale on the positive branch. Not intersects — replaces. If a variable is declared `list[str]` and the predicate returns `TypeGuard[list[object]]`, the branch sees `list[object]`, a *widening*. The negative branch is untouched. `TypeIs[T]` behaves like a user-defined `isinstance`. On the positive branch the checker narrows the declared type to the part of it compatible with `T`, so information is preserved rather than discarded: a parameter declared `Sequence[int]` narrowed by `TypeIs[list[int]]` becomes `list[int]`, and a parameter declared as a subclass narrowed by a predicate for its base stays the subclass. On the negative branch the checker removes `T` from the declared type; when the declared type is a union that leaves the remaining members, and when nothing remains the branch becomes unreachable. ## The constraint that pays for it `TypeIs[T]` is only legal when `T` is assignable to the declared type of the narrowed parameter — the same relationship a real `isinstance` result would have. That constraint is what makes subtracting `T` in the else branch sound. `TypeGuard[T]` has no such requirement, and that freedom is its whole reason to still exist. The canonical case is the invariant container: a parameter typed `list[object]` cannot be narrowed with `TypeIs[list[str]]`, because `list[str]` is not assignable to `list[object]` — `list` is invariant. `TypeGuard[list[str]]` is accepted, precisely because it promises nothing about the negative branch and therefore needs no relationship. The same reasoning covers narrowing a decoded `dict[str, object]` to a `TypedDict` shape. ## Choosing between them The rule of thumb is short: if your predicate answers *is this value one of these*, use `TypeIs`; if it answers *can I treat this value as that*, use `TypeGuard`. Practically, `TypeIs` is the better default for anything expressible as a subtype test, because the two-way narrowing removes a class of annoying false errors in `else` branches and forces you to keep the predicate honest — a `TypeIs` predicate that returns False for a value that *is* of type `T` will mislead the checker just as badly as one that returns True for a value that is not, since the else branch now carries a claim too. `TypeGuard` remains correct for parameterized containers, `TypedDict` shapes, fixed-length tuples, and any conclusion that is not a subtype relationship. It also stays the only option if you must support Python 3.10 through 3.12, where `TypeIs` is not in the standard library. ## Symmetry of obligations With `TypeGuard` your only obligation is: when I return True, the value really is `T`. Returning False for a value that happens to be `T` is harmless, because the checker draws no conclusion from False. With `TypeIs` the obligation is symmetric. Returning False for a value of type `T` now actively lies: the checker subtracts `T` in that branch and will let the code treat the value as something it is not. A predicate that checks a subset of the type — say it accepts only non-empty instances — is therefore a fine `TypeGuard` and a broken `TypeIs`. That subtlety is a good senior follow-up and a common source of quiet bugs. ## Versions `typing.TypeGuard` landed in Python 3.10 (PEP 647). `typing.TypeIs` landed in Python 3.13 (PEP 742) after several years of complaints about one-way narrowing. Nothing about either changed in 3.14; on 3.12 and older, only `TypeGuard` is available from the standard library. Neither has runtime effect: both predicates return an ordinary `bool`.

  • Why is TypeIs rejected for a predicate narrowing a list[object] parameter to list[str]?
    Because `TypeIs[T]` requires `T` to be assignable to the parameter's declared type, and `list` is invariant, so `list[str]` is not assignable to `list[object]`. Without that relationship, subtracting the type in the else branch would be meaningless. `TypeGuard[list[str]]` is accepted because it promises nothing about the negative branch.
  • What goes wrong if a TypeIs predicate returns False for a value that really is of the guarded type?
    The else branch becomes a lie. The checker subtracts the guarded type there, so the code may treat the value as another union member and call methods it does not have. A predicate that accepts only a subset of a type — non-empty instances, say — is a legitimate TypeGuard but a broken TypeIs, and the failure shows up only at runtime.
  • How does the positive branch differ between the two forms?
    TypeGuard replaces the type outright with the guarded type, even if that is a widening. TypeIs narrows to the compatible part of the declared type, keeping information: a parameter declared as a subclass, tested by a predicate for its base class, stays the subclass under TypeIs but would be flattened to the base under TypeGuard.

TypeGuard is a bouncer who shouts a name when he recognises someone and stays silent otherwise — silence tells you nothing. TypeIs is a bouncer with a full guest list: a no is as informative as a yes.

saying these in an interview costs you the question

  • Saying TypeGuard also narrows the else branch
  • Treating the two as interchangeable spellings
  • Missing that TypeIs requires an assignable target type
  • Claiming TypeIs works for list[object] to list[str]
  • Assuming TypeGuard intersects rather than replaces the type
  • Thinking either form checks anything at runtime

context