Python type hints: 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


😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸


Subscribe via RSS, Twitter, Mastodon, or email:

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

Related posts:

Tags: ,