keyboard-shortcut
d

typeguard

3min read

an image

TypeGuard in Python

TypeGuard is a typing feature that lets you teach the type checker how a boolean-returning function narrows types when it returns True.

It was introduced in PEP 647 and is available from:

  • Python 3.10+ via typing.TypeGuard
  • Python 3.8–3.9 via typing_extensions.TypeGuard

The Problem TypeGuard Solves

Consider this common pattern:

from typing import Union

def is_str(x: Union[str, int]) -> bool:
    return isinstance(x, str)

def f(x: Union[str, int]) -> None:
    if is_str(x):
        reveal_type(x)  # still Union[str, int]

Even though you know x is a str, the type checker does not. Why? A normal bool return does not convey any information about what changed when the function returned True.

Enter TypeGuard

TypeGuard lets you say, if this function returns True, then the argument is of a specific type.

from typing import TypeGuard, Union

def is_str(x: Union[str, int]) -> TypeGuard[str]:
    return isinstance(x, str)

def f(x: Union[str, int]) -> None:
    if is_str(x):
        reveal_type(x) # str

Now the type checker trusts the narrowing.

Key points:

  • The return type must be TypeGuard[...].
  • The function must return bool.

The narrowed type applies only when the function returns True. Note that type narrowing is one-way (see later). This is essentially a customisable version of isinstance, e.g.

def is_non_empty_str(x: object) -> TypeGuard[str]:
    return isinstance(x, str) and len(x) > 0

Narrowing to Protocols

TypeGuard works well with structural typing.

from typing import Protocol, TypeGuard

class HasClose(Protocol):
    def close(self) -> None: ...

def has_close(obj: object) -> TypeGuard[HasClose]:
    return hasattr(obj, "close")

def f(x: object):
    if has_close(x):
        x.close() # type-safe

This is extremely useful for duck-typed APIs.

Narrowing Containers

from typing import Iterable, TypeGuard

def all_strings(xs: Iterable[object]) -> TypeGuard[list[str]]:
    return isinstance(xs, list) and all(isinstance(x, str) for x in xs)

def f(xs: Iterable[object]):
    if all_strings(xs):
        xs.append("hello") # ok

⚠️ Be careful: you are promising that all elements satisfy the type.

TypeGuard and runtime behaviour

TypeGuard ONLY informs the typechecker. If your custom check is wrong, your program may crash at runtime.

TypeGuard and Else Branches

We discussed that type guards only narrow one way.

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

def f(x: str | int):
    if is_str(x):
        reveal_type(x) # str
    else:
        reveal_type(x) # str | int (unchanged!)

If you want two-sided narrowing you have to use multiple guards.

TypeGuard vs type is

Python 3.13 introduces type is (PEP 742). This is exactly the same, just more explicit.

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

TypeGuard with Generics

from typing import TypeVar, Iterable, TypeGuard

T = TypeVar("T")

def is_non_empty(xs: Iterable[T]) -> TypeGuard[list[T]]:
    return isinstance(xs, list) and len(xs) > 0

Overusing TypeGuard

Put simply, if isinstance works, use it instead.