Skip to content

← Precise Type Hints with Literals and Generics step 20 of 26

Hard Framework

Write Your Own Narrowing Check with TypeIs

You move an isinstance check into a helper so you can reuse it. The check still works at runtime, but mypy stops narrowing:

def is_str(x: object) -> bool:
    return isinstance(x, str)


def shout(value: str | int) -> str:
    if is_str(value):
        return value.upper()
    return str(value)
Item "int" of "str | int" has no attribute "upper"

A plain bool tells mypy nothing about value. Give the helper a return type of TypeIs[str] instead. A function whose return type is TypeIs or TypeGuard is a narrowing function. It returns a bool at runtime, and its return type tells mypy what a True result means.

  • TypeIs[R] narrows both branches. When it returns True, the value is an R. When it returns False, the value is not an R, so mypy removes R from the type. For value: str | int, the else branch gets int.
  • TypeGuard[R] narrows only the True branch. The else branch keeps the original type, str | int.

mypy trusts these annotations. It does not check that the function body matches the annotation. This exercise includes one wrong annotation on purpose, so you can see what happens. TypeIs needs Python 3.13 or newer.

What to write

  • is_str(x: object) -> TypeIs[str] returns isinstance(x, str). Change the starter's TypeGuard to TypeIs.
  • all_ints(xs: list[object]) -> TypeGuard[list[int]] returns True when every element has exactly the type int. Use type(x) is int, so True, 2.0 and subclasses of int do not count. An empty list counts. It must be TypeGuard, because TypeIs needs the narrowed type to be a subtype of the input type, and list is invariant.
  • is_non_empty(s: str | None) -> TypeIs[str] returns True when s is not None and is not empty. A string of spaces is not empty. This annotation is deliberately wrong. Keep it, so unsound can show the problem.
  • classify(items: list[object]) -> tuple[list[str], list[str]] uses is_str. Strings go into the first list as item.upper(). For every other item, type(item).__name__ goes into the second list. Keep the order and any duplicates. An empty string is a string.
  • total(xs: list[object]) -> int returns sum(xs) when all_ints(xs) is true, and -1 otherwise. An empty list gives 0.
  • unsound(values: list[str | None]) -> list[str] calls is_non_empty on each value. On True, it appends f"set:{value}". Otherwise it appends f"missing:{value!r}". Keep the input order.
  • solve(items: list[object], numbers: list[object], maybe: list[str | None]) returns (strings, others, total(numbers), unsound(maybe)), annotated as tuple[list[str], list[str], int, list[str]]. Do not change the inputs.

Example

assert solve(["a", 1, None, "b"], [1, 2, 3], ["x", "", None]) == (
    ["A", "B"], ["int", "NoneType"], 6, ["set:x", "missing:''", "missing:None"],
)

Look at missing:''. In the else branch of unsound, mypy believes value is None, because TypeIs[str] says that no string gets there. The empty string gets there anyway. Nothing crashes here, but code that relied on value being None would be wrong.

Going further

For a real "non-empty string" check, use TypeGuard[str], or return a plain bool and check for None yourself. Use TypeIs[str] only when the check accepts every string. On Python 3.12, the typing_extensions package provides TypeIs. The typing specification has the full rules.