Python Type Hints - How to Narrow Types with TypeGuard

2021-06-09 Much narrowed types

I previously covered type narrowing using isinstance(), assert, and Literal. In today’s post we’ll cover TypeGuard, a new special type that allows us to create custom type narrowing functions.

TypeGuard was defined in PEP 647, and is available on Python 3.10+, or on older versions from typing-extensions. Guido van Rossum added support to Mypy in version 0.900, which was released yesterday.

Recall that type narrowing uses particular expressions to infer that, in a given block, a variable has a more limited type than its definition. For example, using isinstance():

from __future__ import annotations

name: str | None

if isinstance(name, str):
    # name must be 'str'
else:
    # name must be None

Type checkers, including Mypy, support a limited number of expressions, such as if isinstance(...). But the number of potentially type-narrowing expressions is infinite, especially for parameterized types such as containers. TypeGuard allows us to write type any expression and communicate to our type checker that it narrows types.

A type narrowing function is one that accepts at least one argument and returns a bool. Instead of marking the return type as bool, we use TypeGuard[T], where True means the first argument has type T, and False means it does not. Take this example, adapted from the PEP:

from __future__ import annotations

from typing_extensions import TypeGuard


def is_str_list(value: list[object]) -> TypeGuard[list[str]]:
    """Are all list items strings?"""
    return all(isinstance(x, str) for x in value)


x: list[object]

reveal_type(x)
if is_str_list(x):
    reveal_type(x)

is_str_list() returns True if the given list contains only strings. We tell Mypy this can narrow the type of value to list[str] with the TypeGuard return type.

Running Mypy on this file, we see this output from the reveal_type() calls:

$ mypy --strict example.py
example.py:13: note: Revealed type is "builtins.list[builtins.object]"
example.py:15: note: Revealed type is "builtins.list[builtins.str]"
example.py:16: note: Revealed type is "builtins.list[builtins.object]"

The second note shows us that within the if block, Mypy knows x must be a list of strings. This allows us to use the list items as strs there without any errors. Great!

TypeGuard is flexible as it allows us to write arbitrary code to narrow expressions. It does force us to move even short expressions into separate functions, but that can often a good thing for our code’s readability.

Because there are infinite possible expressions, type checkers cannot validate that our expressions line up with the guarded types. So we need to write our TypeGuard functions with care, and test them thoroughly.

PEP 647 also shows generic TypeGuard functions with TypeVar, but when I tried out the examples, I found Mypy 0.901 does not yet support them. There are a few open issues for TypeGuard so it seems Mypy could use our contributions here!

Fin

May your types be well guarded,

—Adam


🎉 My book Speed Up Your Django Tests is now up to date for Django 3.2. 🎉
Buy now on Gumroad


Subscribe via RSS, Twitter, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: mypy, python