Less Booleans
You orginally had a binary state, and used a boolean to represent it. A third state gets added, what now?
- Break the API by changing the
boolto something else - Add another
bool, creating invalid state combinations
If you wrote your Python with more fitting types I’d argue this issue would have been avoided from the start, and you’d also have more robust and self documenting code.
Consider a function that needs a user’s status. Initially, a user can only be active or inactive. A boolean seems perfect.
def something_user_status(user_id: int, is_active: bool) -> None:
# ... implementation ...
Later, a new “In Meeting” status is required. Do you add a breaking change or another boolean? let’s have a look at another boolean :
def something_user_status(user_id: int, is_active: bool, is_in_meeting: bool) -> None:
# ... implementation ...
This design is flawed. It uses two variables to represent a single concept, and it allows for invalid state combinations, such as a user being both inactive and in a meeting (is_active=false, is_in_meeting=True). This moves error handling from the type system into runtime logic. As we add more states, the number of combinations increases exponentially.
Alternatives to Boolean Flags
More expressive types allow you to add more states as you need without breaking changes, and can make invalid states unrepresentable. They are also nicer to read than (especially positional) booleans. The intent of something_user_status(123, True, False) is unclear without looking the function’s definition.
1. enum.Enum
For a fixed, mutually exclusive set of named options, enum.Enum is the standard solution but I think that there’s better choices depending on the use case.
2. typing.Literal
For simple cases, typing.Literal allows a function to accept a specific set of string (or other literal) values.
from typing import Literal
def set_alignment(alignment: Literal["left", "center", "right"]):
# type checker will flag any value other than the three allowed strings
# ... implementation ...
set_alignment("center") # ok
# set_alignment("middle") # static analysis error
This is especially nice as it keep everything neatly tied in the function signature, and also means the user doesn’t need to import the Enum.
3. Union of Dataclasses (poor mans ADT)
When different states must carry different data, a union of dataclasses is great. It’s basically a poor mans algebraic data types (ADTs).
For our UserStatus example, an Inactive state might need a last_seen timestamp, while an Away state could have a custom message.
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Active:
pass
@dataclass
class Inactive:
last_seen: datetime
@dataclass
class Away:
message: str
# user status is a sum of the individual states
UserStatus = Active | Inactive | Away
What’s even better is that you can now match on these values. As Python’s typing and static analysis ecosystem gets stronger, you’ll be able to move more and more logic to the type system instead of runtime prayers.
def handle_status(status: UserStatus):
match status:
case Active():
print("User is online.")
case Inactive(last_seen):
print(f"User is inactive. Last seen: {last_seen.isoformat()}")
case Away(message):
print(f"User is away: '{message}'")
# usage is type-safe and explicit
handle_status(Inactive(last_seen=datetime.now()))
handle_status(Away(message="On lunch"))