Python Type Hints - How to Avoid “The Boolean Trap”

2021-07-10 Well that looks like a finger trap.

“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.

The Trap

Take this function definition:

def round_number(value: float, up: bool) -> float:
    ...

The function will round numbers, either up or down. We can select the direction by passing up=True or up=False respectively.

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:

def round_number(value: float, *, up: bool) -> float:
    ...

Now call sites must declare up=True or up=False. But that leads us to…

Second, rounding down requires us to pass up=False. 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 up argument. Either we replace up, which requires us to update all callers, or we add more arguments, which becomes unwieldy.

Solutions

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:

def round_up(value: float) -> float:
  ...


def round_down(value: float) -> float:
  ...

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 round_up_approximately_as_int.

Third, we’re giving up the convenience of a behaviour switching argument. Callers may now need to perform switching themselves, such as:

if round_up:
    rounded = round_up(value)
else:
    rounded = round_down(value)

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 True. This way, call sites would remain explicit, such as round_number(x, up=True) or round_number(y, down=True).

With type hints and a runtime check, this would look like:

from __future__ import annotations

from typing import Literal, overload


@overload
def round_number(
    value: float, *, up: Literal[True], down: Literal[False] = False
) -> float:
    ...


@overload
def round_number(
    value: float, *, up: Literal[False] = False, down: Literal[True]
) -> float:
    ...


def round_number(
    value: float,
    *,
    up: bool = False,
    down: bool = False,
) -> float:
    behaviours = [x for x in [up, down] if x]
    if len(behaviours) != 1:
        raise TypeError("Exactly one rounding behaviour must be specified.")
    ...

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.

3. Enum Argument

We can use an enum type for our behaviour switching argument:

from enum import Enum


class RoundingMethod(Enum):
    UP = 1
    DOWN = 2


def round_number(value: float, method: RoundingMethod) -> float:
    ...

This version makes calls look like round_number(x, RoundingMethod.UP). 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 Literal

We can use a string argument to switch behaviour:

from typing import Literal

RoundingMethod = Literal["up", "down"]


def round_number(value: float, *, method: RoundingMethod) -> float:
    ...

We can use this version like round_number(1.5, method="up").

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.

Fin

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,

—Adam


🎉 My book Speed Up Your Django Tests is now up to date for Django 3.2. 🎉
Buy now on Gumroad


Subscribe via RSS, Twitter, or email:

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

Related posts:

Tags: mypy, python