Python Type Hints - How to Do Exhaustiveness Checking

Well, that looks exhuasting.

Exhaustiveness checking is a very handy type checker feature. It ensures that all possible types of a variable are handled. If your code changes to add another possible type, you can guarantee that exhaustiveness-checked code paths handle the new case.

You can use exhaustiveness checking with any type that contains subtypes, such as unions. It’s most often useful with Literal types and Enums.

In this post we’ll look at:

Buckle up!

One weird trick to exhaustively check unions

Take this example code, which doesn’t yet include an exhaustiveness check:

class Widget:
    datapoint: int | str


def display_datapoint(widget: Widget) -> None:
    if isinstance(widget.datapoint, int):
        print("Integer {widget.datapoint}")
    elif isinstance(widget.datapoint, str):
        print("String {widget.datapoint!r}")

display_datapoint can handle all current types of the union type for Widget.datapoint: int and str. But imagine that you add another type to Widget.datapoint:

class Widget:
    datapoint: int | str | float

The current implementation of display_datapoint() would fail silently and simply not print anything for floats. Unless you deliberately tested all widget code paths with a float, you probably wouldn’t spot this. Finding such problems becomes especially difficult if you have many functions that handle Widgets, spread throughout your code base.

You could guard against this eventuality with an exception:

def display_datapoint(widget: Widget) -> None:
    if isinstance(widget.datapoint, int):
        print("Integer {widget.datapoint}")
    elif isinstance(widget.datapoint, str):
        print("String {widget.datapoint!r}")
    else:
        raise AssertionError(
            f"Unsupported Widget.datapoint {widget.datapoint!r}",
        )

Unfortunately this can only occur at runtime. You may not find out about the problem until you’ve deployed your system to production, breaking something for a real user.

Instead, you can find the problem earlier, when type checking with an ✨ exhaustiveness check ✨:

from typing import assert_never


class Widget:
    datapoint: int | str


def display_datapoint(widget: Widget) -> None:
    if isinstance(widget.datapoint, int):
        print("Integer {widget.datapoint}")
    elif isinstance(widget.datapoint, str):
        print("String {widget.datapoint!r}")
    else:
        assert_never(widget.datapoint)

assert_never() is new in Python 3.11. On older Pythons, use the backport from typing-extensions:

from typing_extensions import assert_never

If you take this new version and add float to Widget.datapoint, Mypy will report an error:

$ mypy example.py
example.py:14: error: Argument 1 to "assert_never" has incompatible type "float"; expected "NoReturn"
Found 1 error in 1 file (checked 1 source file)

This error occurs because the function doesn’t handle float. To pass type checking, you’d need to update the code to handle the new type.

So, what’s going on here? Well, assert_never() takes one argument with the type typing.Never. This is a “bottom type”, or “empty”. It means that the variable passed to assert_never() should not have any valid types, at that point.

The if isinstance(...) statements narrow the type of widget.datapoint. Each statement successively removes a type from the union, temporarily. With the first example Widget class, the union goes from int | str, to just str, to Never (empty). assert_never() requires that its argument has type Never—if there are any types left over at that point, Mypy reports an error.

Essentially, the assert_never() clause tells Mypy “check that after the above ifs, there are no more types allowed for widget.datapoint”. Thus, you have a guarantee that the code handles all cases.

Well ain’t that pretty gosh darn neat.

Literallly exhausting

Literal types allow a limited set of literal values. This makes them perfect for exhaustiveness checking.

Take the below example. The Ship class recently gained a scuttled state, but reaction() does not currently handle it:

from typing import assert_never
from typing import Literal


class Ship:
    state: Literal["seaworthy", "sunk", "scuttled"]


def reaction(ship: Ship) -> str:
    if ship.state == "seaworthy":
        return "A fine vessel."
    elif ship.state == "sunk":
        return "Lost too soon."
    else:
        assert_never(ship.state)

assert_never() works here as well, causing Mypy to report an error for the unhandled case:

$ mypy example.py
example.py:15: error: Argument 1 to "assert_never" has incompatible type "Literal['scuttled']"; expected "NoReturn"
Found 1 error in 1 file (checked 1 source file)

Shiver me timbers!

If you update the code to handle "scuttled", the assert_never() check will pass again.

…and now for Enum

Enums also allow a limited set of values, so exhaustiveness checking also comes in handy with them.

Take this example:

from enum import Enum
from typing import assert_never


class Milk(Enum):
    cow = "Cow"
    almond = "Almond"
    cashew = "Cashew"
    oat = "Oat"


def portion_ml(milk: Milk) -> int:
    if milk is Milk.cow:
        return 100
    elif milk is Milk.almond or milk is Milk.cashew:
        return 120
    else:
        assert_never(milk)

The exhaustiveness check fails because Milk.oat isn’t handled:

$ mypy example.py
example.py:18: error: Argument 1 to "assert_never" has incompatible type "Literal[Milk.oat]"; expected "NoReturn"
Found 1 error in 1 file (checked 1 source file)

Note that you need to use is with enum values to trigger type narrowing. You cannot use == or in checks to narrow enums with Mypy at current (issues #10915 and #13684).

With the match statement

So far all our examples have used chained if statements. The match statement, new in Python 3.10, can express such comparisons with less code. Exhaustiveness checking works there too.

You can rewrite the above Enum example with match like so:

from enum import Enum
from typing import assert_never


class Milk(Enum):
    cow = "Cow"
    almond = "Almond"
    cashew = "Cashew"
    oat = "Oat"


def portion_ml(milk: Milk) -> int:
    match milk:
        case Milk.cow:
            return 100
        case Milk.almond | Milk.cashew:
            return 120
        case _ as unreachable:
            assert_never(unreachable)

The Milk.oat case remains unhandled, which Mypy reports:

$ mypy example.py
example.py:19: error: Argument 1 to "assert_never" has incompatible type "Literal[Milk.oat]"; expected "NoReturn"
Found 1 error in 1 file (checked 1 source file)

Thanks, Mypy.

That final case is an idiom for exhaustiveness checking:

case _ as unreachable:
    assert_never(unreachable)

(As featured in the assert_never() docs.)

case _ matches any remaining case, and as binds the result to a variable. You could use any name, but calling it unreachable is a clue to the reader that this line of code should be, well, unreachable.

Clearing exhaustiveness checks from test coverage

If you run your test suite with Coverage.py, exhaustiveness checks may cause you a little problem. Because the assert_never() clauses are impossible to execute, as verified by Mypy, you’ll not be able to cover them in tests. Thus, you probably want to exclude them from test coverage metrics.

Take this example that has exhaustiveness checks with both if and match:

from enum import Enum
from typing import assert_never


class Direction(Enum):
    up = "Up"


def if_message(direction: Direction) -> None:
    if direction is Direction.up:
        print("The only way is up!")
    else:
        assert_never(direction)


if_message(Direction.up)


def match_message(direction: Direction) -> None:
    match direction:
        case Direction.up:
            print("ONwards and UPwards!")
        case _ as unreachable:
            assert_never(unreachable)


match_message(Direction.up)

Run the code with coverage:

$ coverage erase && coverage run -m example && coverage html
The only way is up!
ONwards and UPwards!
Wrote HTML report to htmlcov/index.html

The report shows 75% coverage, with the exhaustiveness checks missing:

Coverage report of the above example code, with the assert_never clauses highlighted as missing.

(The yellow sections show missed branches, thanks to using branch mode.)

Missing, but uncoverable. Sad times.

You can avoid this sadness by configuring Coverage.py to exclude lines related to exhaustiveness checks. Do this with the exclude_lines option, which takes a list of regexes to match against lines to exclude. For example in pyproject.toml:

[tool.coverage.report]
exclude_lines = [
    '^ +assert_never\(.*?\)$',
    '^ +case _ as unreachable:$',
]

The first regex excludes any line that only calls assert_never(). The second excludes the match idiom, as previously discussed—note you will need to use the exact variable name unreachable.

With this configuration in place, the report shows 100% coverage, with the appropriate blocks excluded:

Coverage report of the example code, showing 100% coverage with appropriate lines excluded.

Heck yeah!

Fin

If you want to read more, the Mypy docs cover exhaustiveness checking with literals and enums.

Thanks to Haki Benita for teaching me about exhaustiveness checking in his 2020 post, before Python considered adding assert_never(). And thanks to Jelle Zijlstra for contributing assert_never() to Python 3.11.

Go forth and ensure you cover all the cases,

—Adam


Learn how to make your tests run quickly in my book Speed Up Your Django Tests.


Subscribe via RSS, Twitter, Mastodon, or email:

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

Related posts:

Tags: ,