avatarMarcin Kozak

Summary

The article discusses the creation of a custom OptionalBool data type in Python to handle variables that can hold None, True, or False values, providing a more robust solution than using Optional[bool] type hints or strings.

Abstract

The Python programming community often encounters scenarios where a variable needs to represent three distinct states: None, True, or False. While the Optional[bool] type hint and string representations can be used to handle such cases, they lack robustness and can lead to errors due to Python's dynamic typing. The article explores two approaches to address this issue: using a type alias with Optional[bool] and creating a custom OptionalBool data structure. The type alias approach is found to be inelegant and error-prone, as it does not prevent invalid value assignments. In contrast, the custom OptionalBool data structure ensures type safety by allowing only None, True, or False values and providing a clear comparison mechanism, thus avoiding common pitfalls associated with dynamic typing in Python. The OptionalBool class is designed to mimic the behavior of bool and NoneType while providing a distinct data type that encapsulates the three-state logic. The article concludes by inviting readers to use or improve upon the proposed OptionalBool implementation and emphasizes the utility of such a data type in real-world programming scenarios.

Opinions

  • The author considers the use of a string variable to represent None, True, or False as a "nasty" solution and prefers a more structured approach.
  • The author believes that while type hints are useful, they do not provide the same level of protection against incorrect value assignments as a custom data structure.
  • The Optional[bool] type alias is seen as clumsy and insufficient for ensuring type integrity in a large codebase.
  • The custom OptionalBool data structure is presented as a superior alternative, offering both a data type and a data structure, which simplifies comparisons and ensures that only valid values are assigned.
  • The author suggests that the OptionalBool class could be further extended or even implemented using an enumeration (Enum), leaving room for community contributions and improvements.
  • The article promotes the idea that necessity drives invention, implying that the OptionalBool type was created to fulfill a practical need in programming.

PYTHON PROGRAMMING

An OptionalBool Type for Python: None, False or True

Use OptionalBool instead of Optional[bool].

Photo by Florian Schmetz on Unsplash

Among the various built-in types we have in Python are the NoneType and the bool types. Incredibly useful and extremely common, both are used in almost all Python projects — if not all. Who doesn’t use None, True and False values in Python?

Sometimes, however, we need to combine these three values within one variable: Optional[bool]. As this type hint suggests, this means you can use a bool value for this variable — but don’t have to. The Optional option often helps in handling optional arguments of functions, but I’m not talking about this situation. I am talking about different situations, once in which the None value carries a particular piece of information. Best to show how this works using an example.

Consider the following situation. Given a list of paths, you check if a path has a .txt extension; if it does, you follow with a check if it is in English. For a particular path, we can encode these three situations as the following values:

  • None: the file is not *.txt
  • False: the file has the .txt extension but is not in English
  • True: the file has the .txt extension and is in English

As you see, such a variable has the Optional[bool] type hint. Python does not offer a built-in data type for such a variable. We can use the Optional[bool] annotation itself; I will discuss this topic later in this article. But this means merely using type hint not a data type (like bool). A type hint is just a hint while a data type is a data type — the former only suggests while the latter requires. As you will see, a data type of this sort can come in handy in various situations, one of them being the variable described above.

To represent it, we could use a two-step bool value; for example, it could be represented by tuple[bool, bool]. Let’s see how such a tuple would work instead of the above-defined three-value type:

  • (False, None): the equivalent of None → the file is not *.txt
  • (True, False): the equivalent of False → the file has the .txt extension but is not in English
  • (True, True): the equivalent of True: the file has the .txt extension and is in English

Such a tuple is far more complicated than a three-value data type — even if we use a named tuple. First, we have two bool values, and this is no fan. Even worse, the meaning of None is unnatural — it means “it is not available, but it does not matter.” Do you see why? Because the user can use (False, True) and (False, False) — and both will carry exactly the same meaning as (False, None). Do you see that it really does not matter what’s the second value of the tuple? I don’t like such it-does-not-matter situations.

There are crude ways to achieve something like this with one variable of a built-in type. For example, you can use a string type and give it one of the following values:

  • "None", the equivalent of None → the file is not *.txt.
  • "False": the equivalent of False → the file has the .txt extension but is not in English
  • "True": the equivalent of True: the file has the .txt extension and is in English

In my opinion, this solution is pretty nasty, something I’ve never used and most likely never will.

In this article, I will show you two solutions, both likely being better than using a string variable. The first of them will be very simple but at the same time a little clumsy to use; this solution will be based on a type hint. I don’t know whether or not it is better than using a string variable, as I would use neither of these solutions.

I’d use something else instead — a dedicated custom data type and at the same time, a custom data structure. In my other article, I wrote that a custom data structure is better than a type alias:

It is so because you get two things in one: both a data type and a data structure. I present such a data type to be in our situation as the second version, and I consider it much more useful and pleasant to use.

Approach 1: Use type alias

You can approach the problem in a simple way. Define the following custom type alias: Optional[bool] and use a variable of this type.

The solution may sound elegant, probably thanks to its brevity. But it’s definitely not such. It will work, but from a coding point of view, it will likely be unpleasant to use, especially in a big coding project. One of the main problems is a complete lack of protection — a variable of such a type can be given any value, including illegal ones — and you will not know this. This can lead to unpleasant errors, sometimes difficult to debug. Worst-case scenario, no error would be thrown whatsoever and the application would run despite the incorrect value.

You can circumvent such errors by adding value checks, for example using the easycheck package:

Value checks are fine in some situations, but when there’s a way to avoid them, it’s better to do it.

While this solution is slightly neater than using a string variable, it’s still neither elegant nor convenient. It’s easy to see this inconvenience. Note that if you want to use several variables of this type, you need to annotate each of them in each scope. Otherwise, it will be a regular Python variable, one that can take any value. Surely, variables of this type can be assigned anything, but their type alias at least points out what sort of values should be used. Should be, not must be.

Okay, let’s check how this solution could work.

>>> from typing import Optional
>>> NoneOrBool = Optional[bool]
>>> true: NoneOrBool = True
>>> false: NoneOrBool = False
>>> none: NoneOrBool = None
>>> true
True
>>> true is True
True
>>> true is None
False
>>> if true: print("This is true!")
This is true!
>>> none is None
True
>>> type(none).__name__
'NoneType'
>>> none
>>> false
False

(I used the name NoneOrBool only because I will use the name OptionalBool later, and I wanted to avoid using the same name for the two solutions.)

This is all fine and works as expected. However, the above code block shows only correct uses of the NoneOrBool type. It takes almost nothing to assign an unacceptable value to a variable of the NoneOrBool type:

>>> nonsense: NoneOrBool = "Nonsense"

Generally, this is how Python works. This is dynamic typing. It disables us to use the typical approach to if comparisons that we used above, because:

>>> if nonsense: print("This is true!")
This is true!

As you see, you can annotate nonsense as a NoneOrBool variable, but it does not mean that this variable will not have a different type — dynamic typing! The if comparison I’ve shown above makes this variable clumsy and incoherent with its annotated type.

So, anytime we use this variable, and you want to ensure that it does have the NoneOrBool type, we need to check it. This is why I prefer the following solution: using a custom data structure.

Approach 2: OptionalBool custom data structure

At first, I was thinking of making OptionalBool an abstract base class (ABC). With a different name, you could make it a type with three values of the user’s choice. After some pondering, however, I decided not to. Just like we have NoneType and the bool type, we need an OptionalBool type, with the three possible values of None, False and True. And if you need a different type with three possible values, you can create your own class.

Here’s the definition of OptionalBool:

# optionalbool.py
from typing import Any, Optional

class OptionalBool:
    def __init__(self, value: Optional[bool] = None):
        if value is not None:
            self._check_value(value)
        self._value = value
        
    @staticmethod
    def _check_value(value: Optional[bool] = None):
        msg = "OptionalBool accepts only None, True, False values"
        if value is not None:
            if value not in (None, True, False):
                raise ValueError(msg)
        
    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, new_value: Optional[bool]):
        self._check_value(new_value)
        self._value = new_value
    
    def __repr__(self):
        return f"{self._value}"
    
    def __eq__(self, other: Any):
        if other is None:
            return self.value is None
        if hasattr(other, "value"):
            return self.value == other.value
        return self.value == other

The class is organized in such a way that you will rarely need to use its .value attribute.

This is how OptionalBool works. First, let’s create two OptionalBool values, x and y.

>>> from optionalbool import OptionalBool
>>> x = OptionalBool()
>>> x
None
>>> y = OptionalBool(True)
>>> y
True

Easy, nothing special — which is good. What we need for this type is comparisons. OptionalBool’s instances have their value, which can be None, False or True. This value is kept by the .value attribute, and you can use it:

>>> x.value
>>> if not x.value:
...     print(f"{x.value = }")
x.value = None
>>> x.value is None
True
>>> if y.value:
...     print(f"{y.value = }")
y.value True

Unfortunately, Python does not enable us to overload the is operator, so we will have to adopt to this situation. Here are the rules for making comparisons of OptionalBool instances. You have to follow these (simple) rules if you want your comparisons to be correct:

  • In no case should you compare an OptionalBool instance using the is operator; this may lead to incorrect conclusions.
  • The only valid comparisons between two OptionalBool instances are those using the == operator (that is, the comparisons using .__eq__()).
  • For a particular instance of OptionalBool, only the following comparisons can be positive with another object (using ==): an OptionalBool instance with another OptionalBool instance with the same value; OptionalBool(None) with None; OptionalBool(True) with True; and OptionalBool(False) with False.

Okay, first some simple comparisons, with another value of OptionalBool type:

>>> x == y
False
>>> x != y
True
>>> x < y
Traceback (most recent call last):
    ...
TypeError: '<' not supported between instances of 'OptionalBool' and 'OptionalBool'
>>> x >= y
Traceback (most recent call last):
    ...
TypeError: '>=' not supported between instances of 'OptionalBool' and 'OptionalBool'

Do remember, however, that such comparisons would behave differently when using the .value attribute of the instances. That’s why if you want to compare two OptionalBool instances, you should not do that using this attribute but the instances themselves, like above.

You can also use the .value attribute for these comparisons, and they will lead the same results. If you look into the implementation of comparisons (.__eq__()), you will see why: because the comparisons of instances (e.g., x == y) use the .value attribute. So, basically, when we run x == y, in fact another comparison is run: x.value == y.value.

When being compared to regular bool values, we want OptionalBool values to behave like bool. As mentioned above, we cannot overload the is operator. Therefore, typical comparisons with instances will not work:

>>> true = OptionalBool(True)
>>> false = OptionalBool(False)
>>> none = OptionalBool()
>>> if true: print("Yup")
Yup
>>> if false: print("Yup")
Yup
>>> if none: print("Yup")
Yup

Thus, here we should use .value:

>>> if true.value: print("Yup")
Yup
>>> if false.value print("Yup")
>>> if none.value print("Yup")
>>> true.value is True
True
>>> false.value is False
True
>>> none.value is None
True

Let’s look once more at the comparisons of none (the OptionalBool instance created above) with None:

>>> none == None
True
>>> none is None
False
>>> none.value is None
True

Comparisons with bool values work the same way:

>>> true is True
False
>>> true == True
True
>>> false is False
False
>>> false == False
True
>>> false.value == False
True
>>> false.value == True
False
>>> false.value is None
False
>>> false.value == None
False
>>> false.value == 10
False

A good thing about OptionalBool is that you can create its instance only with its accepted values: None, False and True:

>>> OptionalBool("nonsense")
Traceback (most recent call last):
    ...
ValueError: OptionalBool accepts only None, True, False values

Along the same lines, you cannot assign an unacceptable value to an OptionalBool instance:

>>> x = OptionalBool(True)
>>> x
True
>>> x.value = "nonsense"
Traceback (most recent call last):
    ...
ValueError: OptionalBool accepts only None, True, False values

In short, comparisons of OptionalBool instances work as follows:

  • In no case should you compare (with nothing) an OptionalBool instance using the is operator; this may lead to incorrect conclusions. The best idea is to forget the is operator for OptionalBool.
  • The only valid comparisons between two OptionalBool instances are those using the == operator.
  • Comparison of OptionalBool instances is not equivalent of comparisons of OptionalBool values (OptionalBool.value).
  • For a particular instance of OptionalBool, only two objects are installed that are positive when compared to this instance: another instance with the same value and the value of the None or bool object is the same as the value of the instance.

Conclusion

Why at all have I proposed OptionalBool? For the simple reason that a type like this can be useful in various real life situations in which we need a variable that takes one of three possible values: None, False or True.

I presented two solutions. The first one was just a custom type, but as such, it does not offer any particular convenience. The second one was an actual data structure, built as a class. Although I offered its particular implementation, to be honest, my main goal was to present the concept of OptionalBool and outline its general functionality. Maybe some of you will need a type like this, and we all know that necessity is the mother of invention. Feel invited to use the implementation proposed in this article; if you come up with another interesting implementation, however, please share it — either in your article or as a comment to this one.

I initially planned to include also a third method that would be based upon the Enum enumeration:

I thought Enum would make things much simpler. I started implementing this type, and after some time it occurred to not be simpler than OptionalBool. This is because I decided to handle various operators and comparisons. For instance, I wanted to make it possible to conduct the following comparison: x == True, where x is an OptionalBool instance. As we discussed above, this is possible in the above implementation, but to achieve the same functionality in an enumeration class inheriting from Enum, we would have to overload the .__eq__() method anyway. Hence, the complexity of both implementation would be close. Hence I did not implement OptionalBool inheriting on Enum but left it to you as an exercise. Who knows, maybe it will be a better implementation?

Let me finish this article by outlining the OptionalBool class:

  • OptionalBool allows for one of the three possible values: None, False and True.
  • OptionalBool(True) should be equal to True; OptionalBool(False) should be equal to False; and OptionalBool(None) should be equal to None.
  • OptionalBool should be both a data structure and a data type. Hence, type(x) when x is an instance of OptionalBool should provide OptionalBool.
  • OptionalBool instances should not be compared using the is operator. Their values, however, can. Hence, you should not compare x is y, where x and y are OptionalBool instances; but you can compare x.value is y.value.
  • OptionalBool instances should be compared using the == operator only. Although you can use other comparison operators for bool values, you should disallow this for OptionalBool instances and values, as they do not make sense for a bool value and a NoneType value.

Thanks for reading. If you enjoyed this article, you may also enjoy other articles I wrote; you will see them here. And if you want to join Medium, please use my referral link below:

Python
Data Science
Python Programming
Python3
Oop
Recommended from ReadMedium