Custom narrowing and exhaustiveness

typing · Advanced · 12 min

Reusable checks and exhaustive matches turn 'forgot a case' bugs into checker errors.

Built-in narrowing understands isinstance, is None and == on literals. To narrow with your own function, annotate its return type specially.

`TypeIs[T]` (3.13, PEP 742): def is_str_list(x: object) -> TypeIs[list[str]]. If it returns True, x is narrowed to list[str]; if False, the checker removes that type in the else branch, just like isinstance. The narrowed type must be consistent with the input type.

`TypeGuard[T]` (3.10) is the older form. It narrows only in the true branch and allows narrowing to an unrelated type (like list[object] to list[str]). Prefer TypeIs in new code.

Exhaustiveness: when every member of a union or enum has been handled, the remaining type is Never, the empty type. assert_never(x) accepts only Never, so if someone adds a new member and forgets a branch, the call becomes a checker error. At runtime it raises if reached.

match shape:
    case Circle(): ...
    case Square(): ...
    case _: assert_never(shape)

Never (or NoReturn) is also the return type of functions that always raise or exit.

Example

from enum import Enum
from typing import TypeGuard, TypeIs, assert_never

class Status(Enum):
    ACTIVE, BANNED = 1, 2

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

def is_str_list(val: list[object]) -> TypeGuard[list[str]]:   # list is invariant,
    return all(isinstance(x, str) for x in val)               # so TypeIs can't be used

def label(s: Status) -> str:
    match s:
        case Status.ACTIVE: return "ok"
        case Status.BANNED: return "blocked"
        case _:
            assert_never(s)        # checker error here if a member is unhandled

def show(x: int | str) -> str:
    return x.upper() if is_str(x) else str(x + 1)   # else branch: x is int

items: list[object] = ["a", "b"]
if is_str_list(items): print(", ".join(items))   # a, b  (items: list[str])
print(show("hi"), show(1), label(Status.BANNED))   # HI 2 blocked

Remember

  • TypeIs[T] narrows in both branches like isinstance (3.13)
  • TypeGuard[T] narrows only when True and allows unrelated types
  • assert_never with Never makes the checker enforce exhaustive handling

Watch out for

  • Writing a TypeIs function whose runtime check doesn't actually prove the type; the checker trusts it blindly.

Check yourself

  1. You add Status.PENDING but forget to handle it in a match ending with assert_never(s). When is this caught?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/typing.html#typing.TypeIs