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

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 returnsFalse
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:
The type hint is wrong.
For example, perhaps
cheese
should be allowed to beNone
. Maybe some callers passNone
, 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: ...
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:
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.list
s andtuple
s are sequences, but not generator expressions.from collections.abc import Sequence ... def consume(cheeses: Sequence[Cheese]) -> None: ...
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 if
s
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.
Learn how to make your tests run quickly in my book Speed Up Your Django Tests.
One summary email a week, no spam, I pinky promise.
Related posts: