Python Type Hints - How to use Mypy’s always-true boolean check detection

Redundant-looking science apparatus

Sometimes code uses boolean checks on variables that can only be true. This is normally a sign of a mistake, either in the type hints or the implementation. Mypy has an optional check that can find such problematic boolean usage with its truthy-bool error code.

A cheesy example

Take this code:

class Cheese:
    def __init__(self, name: str) -> None:
        self.name = name


def consume(cheese: Cheese) -> None:
    if cheese:
        print(f"Yum, {cheese.name}!")

The if cheese: clause is redundant. To execute the if, Python will use its truth-checking process:

By default, an object is considered true unless its class defines either a __bool__() method that returns False or a __len__() method that returns zero.

Cheese is a vanilla Python class, with no custom __bool__() or __len__() methods, so it will always be truthy. Hence, the body of the if clause always be executed.

Mypy has an opt-in error for such redundant boolean checks. You can activate the error by adding truthy-bool to the enable_error_code setting. For example in pyproject.toml:

[tool.mypy]
enable_error_code = [
    "truthy-bool",
]

Mypy then reports an error at the if line:

$ mypy example.py
example.py:7: error: "cheese" has type "Cheese" which does not implement __bool__ or __len__ so it could always be true in boolean context
Found 1 error in 1 file (checked 1 source file)

Resolving the problem requires judgment. There are two possible causes for the redundant check:

  1. The type hint is wrong.

    For example, perhaps cheese should be allowed to be None. Maybe some callers pass None, but aren’t (yet) type-checked, so you haven’t seen errors there. If so, the signature could be expanded:

    def consume(cheese: Cheese | None) -> None:
        ...
    
  2. The implementation is wrong.

    Probably the if block is indeed redundant, perhaps due to some refactoring. It should be removed to clarify the code:

    def consume(cheese: Cheese) -> None:
        print(f"Yum, {cheese.name}!")
    

    Or, it may be the case that Cheese.__bool__() should be defined.

Okay then.

More subtly wrong: misuse of Iterable

Consider this multi-cheese version of consume():

from collections.abc import Iterable


class Cheese:
    def __init__(self, name: str) -> None:
        self.name = name


def consume(cheeses: Iterable[Cheese]) -> None:
    if not cheeses:
        print("hungry!")
        return

    for cheese in cheeses:
        print(f"Yum, {cheese.name}")

It looks reasonable, and one can call it with lists and get the expected result:

In [1]: consume([])
Hungry!

In [2]: consume([Cheese("Camembert"), Cheese("Gruyère")])
Yum, Camembert
Yum, Gruyère

But it has a subtle wrongness, the worst kind of wrongness.

With truthy-bool active, Mypy highlights that cheeses “could always be true in boolean context”:

$ mypy example.py
example.py:10: error: "cheeses" has type "Iterable[Cheese]" which does not implement __bool__ or __len__ so it could always be true in boolean context
Found 1 error in 1 file (checked 1 source file)

So, what is the issue here?

Well, Iterable just declares that the variable has an __iter__() method. It doesn’t require that the passed in-object has a __bool__() or __len__(), and thus it could always be true.

The above test uses a list iterable, which has a __bool__() method, so it won’t always be true. But, generator expressions only have __iter__() and are otherwise always true. If you call this bad consume() with an empty generator expression, it simply does nothing:

In [3]: zero_cheeses = (Cheese("Cheddar") for _ in range(0))

In [4]: consume(zero_cheeses)

In [5]:

Oh no!

The if not cheeses clause is skipped for the empty generator expression.

As above, there are two possible causes:

  1. The type hint is wrong.

    If the function doesn’t actually need to handle any iterable, you could update it to use Sequence in its type hint. A sequence is an iterable with __getitem__() and __len__() methods, hence it is not always-true. lists and tuples are sequences, but not generator expressions.

    from collections.abc import Sequence
    
    ...
    
    
    def consume(cheeses: Sequence[Cheese]) -> None:
        ...
    
  2. The implementation is wrong.

    If any iterable does need to be handled, the function can be updated to do so:

    def consume(cheeses: Iterable[Cheese]) -> None:
        yummed = False
        for cheese in cheeses:
            yummed = True
            print(f"Yum, {cheese.name}")
    
        if not yummed:
            print("Hungry!")
    

Phew.

It’s not just ifs

Mypy applies this check anywhere Python would interpret the variable in a boolean context. Here are a couple more bad examples that it would flag for.

In an or expression, where the right hand side would never be executed:

def mature(cheese: Cheese) -> None:
    cheese = cheese or Cheese("Cheddar")
    ...

In an while which would be an infinite loop:

cheese = Cheese("Gorgonzola")
while cheese:
    ...

Fun.

Fin

May your boolean checks always be crystal clear,

—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: ,