Python Type Hints - How to Avoid “The Boolean Trap”2021-07-10
“The Boolean Trap” is a programming anti-pattern where a boolean argument switches behaviour, leading to confusion. In this post we’ll look at the trap in more detail, and several ways to avoid it in Python, with added safety from type hints.
Take this function definition:
The function will round numbers, either up or down.
We can select the direction by passing
The trap is threefold.
First, call sites are unclear.
round_number(value, True) does not spell out the direction of rounding.
Readers may assume the wrong direction and get confused.
We can avoid this first problem by making
up a keyword-only argument.
We need only add a
* argument separator:
Now call sites must declare
But that leads us to…
Second, rounding down requires us to pass
It’s not obvious this means “down” - it only says “not up”.
Could it mean “sideways”?
Even in domains where are there are two clear options, double negatives like “not up” still need mental work to decipher.
Third, the argument has no room to expand. Although the author may not have known it at the time, there are many more ways to round numbers. These include “half up”, “half down”, and “half away from even”. (For an in-depth guide, see this Real Python article.)
If we later need to add a new rounding method, we cannot stick with the
Either we replace
up, which requires us to update all callers, or we add more arguments, which becomes unwieldy.
Let’s look at four alternative designs that can avoid the trap.
I’d generally lean towards using multiple functions (#1) for simple cases, and a string literal (#4) otherwise.
1. Multiple Functions
We could make one function per behaviour:
Such an API is laudably more explicit, and a great idea for simple situations with few behaviours. But for cases with more behaviours there are some disadvantages.
First, it may be hard to factor the code as separate functions. We may avoid repetition by having our public functions call a shared private function with a behaviour switching argument. This presents us with the boolean trap again.
Second, we may need a large number of functions.
Each boolean flag avoided doubles the number of required functions.
We might end up with many functions with long names like
Third, we’re giving up the convenience of a behaviour switching argument. Callers may now need to perform switching themselves, such as:
2. Mutually Exclusive Flags
We could stick with boolean arguments, but have one per behaviour.
We would make the arguments keyword-only, defaulting to
False, and check that exactly one is set to
This way, call sites would remain explicit, such as
round_number(x, up=True) or
With type hints and a runtime check, this would look like:
To spell out the permitted calling formats, we need to use the
@overload decorator with
Literal for each boolean argument’s value.
We check the values at runtime, presuming that not all callers use type checking.
This design is great for call sites, but not so much fun to write.
The most significant disadvantage here is the verbosity.
It’s so much code!
To add a new behaviour we must us to write a new
@overload case, define the argument as
Literal[False] in every other case, and add the argument to the base function.
This verbosity will also appear in our documentation, where we need to list and explain to explain the arguments.
Another negative is that, like the multiple function design, we’re giving up the convenience of a behaviour switching argument.
We can use an enum type for our behaviour switching argument:
This version makes calls look like
We don’t need to use a keyword-only argument as the word “method” is in the enum name.
Adding a new behaviour is easy - we need only add one line to the enum, and then define the behaviour. We can also use exhaustiveness checking to ensure we cover all behaviours.
The only disadvantage is a little extra verbosity: callers need to import the enum, and
RoundingMethod.UP is not the shortest.
4. String Argument with
We can use a string argument to switch behaviour:
We can use this version like
This option is very similar to using an
Enum, but less code.
By making the
RoundingMethod type alias, we make the type importable for any type checked client code.
For a showcase of API’s that fell into the trap, see Ariya Hidayat’s article. And for another take on The Boolean Trap in Python, check out Anthony Sottile’s video.
May George Boole never trap you again,
🎉 My book Speed Up Your Django Tests is now up to date for Django 3.2. 🎉
Buy now on Gumroad
One summary email a week, no spam, I pinky promise.
- Python Type Hints - How to Use typing.Literal
- Python Type Hints - How to Use @overload
- Python Type Hints - How to Fix Circular Imports
Tags: mypy, python
© 2021 All rights reserved.