Files
blog/content/post/algebraic-python-enums.md

348 lines
7.5 KiB
Markdown
Raw Permalink Normal View History

2025-11-02 19:42:33 +05:30
---
title: "Algebraic Python Enums"
date: 2025-11-02T19:08:46+05:30
2025-11-02 21:36:34 +05:30
draft: false
tags:
- Python
- Decorators
- Rust
- Algebraic Data Types
2025-11-02 19:42:33 +05:30
---
2025-11-19 08:12:09 +05:30
University has compelled me to use Python despite my preference for Rust,
primarily due to the machine learning and data science hype. One
Rust feature that I dearly miss is enumerable data types
that can encapsulate various other data types.
2025-11-02 19:42:33 +05:30
2025-11-03 08:21:38 +05:30
Although Python has the answer to creating structs as
[dataclasses](https://peps.python.org/pep-0557/), including support for
[structural match expressions](https://peps.python.org/pep-0636/) in recent
versions, most tutorials will suggest `Union` types as the equivalent to Rust's
enums.
2025-11-02 19:42:33 +05:30
2025-11-03 08:21:38 +05:30
> I highly encourage you to try out the code snippets and follow along with this article.
Use the collapse explanation button to copy multiple code blocks in one go.
2025-11-02 19:42:33 +05:30
2025-11-03 08:21:38 +05:30
## Naive draft
{{< collapsable-explanation >}}
2025-11-02 21:36:34 +05:30
2025-11-02 19:42:33 +05:30
```python
2025-11-02 21:36:34 +05:30
# glass_enum.py
2025-11-02 19:42:33 +05:30
from dataclasses import dataclass
@dataclass
class Empty:
pass
@dataclass
class Full:
drink: str
Glass = Empty | Full
```
This allows us to define functions that ingest the `Glass` datatype.
```python
2025-11-02 21:36:34 +05:30
def report_drink(glass: Glass) -> str:
2025-11-02 19:42:33 +05:30
match glass:
2025-11-02 22:04:27 +05:30
case Empty():
2025-11-02 19:42:33 +05:30
return "Whoops, looks like you've finished your drink!"
case Full(drink):
return f"Ah a {drink}, what a fine taste!"
```
2025-11-03 08:21:38 +05:30
For example
```python
dr_pepper = Full('Dr. Pepper')
print(report_drink(dr_pepper))
```
```python
# glass_enum.py
from dataclasses import dataclass
@dataclass
class Empty:
pass
@dataclass
class Full:
drink: str
Glass = Empty | Full
def report_drink(glass: Glass) -> str:
match glass:
case Empty():
return "Whoops, looks like you've finished your drink!"
case Full(drink):
return f"Ah a {drink}, what a fine taste!"
dr_pepper = Full('Dr. Pepper')
print(report_drink(dr_pepper))
```
{{< /collapsable-explanation >}}
Will output
```
Ah a Dr. Pepper, what a fine taste!
```
2025-11-02 21:36:34 +05:30
## Pitfalls
2025-11-02 19:42:33 +05:30
2025-11-02 21:36:34 +05:30
### No direct variant access
2025-11-02 19:42:33 +05:30
2025-11-03 08:21:38 +05:30
{{< collapsable-explanation >}}
What if we had another `Union` with same variant names in the same file?
```python
@dataclass
class Full:
gold: int
gems: int
@dataclass
class Empty:
pass
Inventory = Full | Empty
player_inventory = Full(500, 50)
```
Now we try to instantiate a `Glass` `Full` of `lemonade`.
```python
lemonade = Full("lemonade")
```
```python
@dataclass
class Full:
gold: int
gems: int
@dataclass
class Empty:
pass
Inventory = Full | Empty
player_inventory = Full(500, 50)
lemonade = Full("lemonade")
```
{{< /collapsable-explanation >}}
Python will error out since `Full` now refers to the new variant of the
union type `Inventory`.
```
Traceback (most recent call last):
File "<python-input-11>", line 1, in <module>
lemonade = Full("lemonade")
TypeError: Full.__init__() missing 1 required positional argument: 'gems'
```
We can't instantiate variants as members of the `Glass` namespace. The following code does not work.
2025-11-02 19:42:33 +05:30
```python
dr_pepper = Glass.Full("Dr. Pepper")
```
2025-11-03 08:21:38 +05:30
This can be partially solved by keeping just the `Glass` type inside a module.
2025-11-02 21:36:34 +05:30
Here we have saved the file as `glass_enum.py`. From a different module we can
access the variants as `glass_enum.Empty` and `glass_enum.Full`.
```py
# main.py
import glass_enum
fanta = glass_enum.Full('Fanta')
empty = glass_enum.Empty()
```
Now any function outside the module has to ingest a rather confusing argument of type `glass_enum.Glass`.
```python
def refill(glass: glass_enum.Glass) -> glass_enum.Glass:
# ...
return glass
```
2025-11-03 08:21:38 +05:30
Since module namespacing only causes more confusion, we will discard this idea.
2025-11-02 21:36:34 +05:30
### No methods on the enum itself
2025-11-03 08:21:38 +05:30
Python also disallows methods from being defined on `Union` types.
2025-11-02 21:36:34 +05:30
In the case of our concrete example, we can't add methods to the `Glass` type.
2025-11-03 08:21:38 +05:30
The following code uses a hypothetical `is_empty()` method on the `Glass` union
type which is not allowed. Hence the code won't run.
2025-11-02 21:36:34 +05:30
```python
def refill(glass: Glass) -> Glass:
if glass.is_empty(): # can't implement on type `Glass` directly
return Full('water')
return glass
```
2025-11-03 08:21:38 +05:30
To define a method like `is_empty()`, it must be implemented on both the classes
`Full` and `Empty`. This gets tedious for 3 or more variants.
2025-11-02 21:36:34 +05:30
## Python is a sneaky language
Last week I discovered that Python allows creating nested classes to keep things organized.
```python
from dataclasses import dataclass
class Glass:
@dataclass
class Empty:
pass
@dataclass
class Full:
drink: str
```
Python will happily run the above code and we can access the "variants" under the `Glass` namespace.
```python
lemonade = Glass.Full('lemonade')
```
If only we could register the variants as the `Glass` type itself and inherit all its methods.
### Redecorate
We can define a decorator that takes all of the nested dataclasses and makes them inherit the outer class.
2025-11-03 08:21:38 +05:30
{{< collapsable-explanation >}}
2025-11-02 21:36:34 +05:30
```python
def AlgebraicEnum(cls):
2025-11-03 08:42:42 +05:30
for name, nested in cls.__dict__.items():
if isinstance(nested, type):
setattr(cls, name, type(name, (cls, nested), {}))
2025-11-02 21:36:34 +05:30
return cls
```
2025-11-03 08:21:38 +05:30
The inheritance means all methods of the outer class are available on the nested
2025-11-14 10:08:22 +05:30
classes via the method resolution order chain _and_ any object of a nested class
`isinstance` of the outer class.
2025-11-03 08:21:38 +05:30
2025-11-14 10:08:22 +05:30
That's all there is to the magic! Simply adding this decorator above the
previous class declaration make variants like `Glass.Empty` and `Glass.Full`
inherit `Glass`.
2025-11-02 21:36:34 +05:30
```python
from dataclasses import dataclass
@AlgebraicEnum
class Glass:
@dataclass
class Empty:
pass
@dataclass
class Full:
drink: str
2025-11-03 08:21:38 +05:30
def report_drink(self: 'Glass') -> str:
2025-11-02 21:36:34 +05:30
match self:
2025-11-02 22:04:27 +05:30
case Glass.Empty():
2025-11-02 21:36:34 +05:30
return "Whoops, looks like you've finished your drink!"
case Glass.Full(drink):
return f"Ah a {drink}, what a fine taste!"
2025-11-03 08:21:38 +05:30
def is_empty(self: 'Glass') -> bool:
2025-11-02 21:36:34 +05:30
match self:
2025-11-02 22:04:27 +05:30
case Glass.Empty():
2025-11-02 21:36:34 +05:30
return True
return False
```
Note how the `report_drink` method accepts a `self` of type `Glass` and the match arms
compare it with `Glass.Empty` and `Glass.Full`.
2025-11-03 08:21:38 +05:30
The following code runs just fine.
```python
diet_coke = Glass.Full('diet coke')
empty = Glass.Empty()
print(diet_coke.report_drink())
print(empty.is_empty())
```
```python
from dataclasses import dataclass
def AlgebraicEnum(cls):
2025-11-03 08:42:42 +05:30
for name, nested in cls.__dict__.items():
if isinstance(nested, type):
setattr(cls, name, type(name, (cls, nested), {}))
2025-11-03 08:21:38 +05:30
return cls
@AlgebraicEnum
class Glass:
@dataclass
class Empty:
pass
@dataclass
class Full:
drink: str
def report_drink(self: 'Glass') -> str:
match self:
case Glass.Empty():
return "Whoops, looks like you've finished your drink!"
case Glass.Full(drink):
return f"Ah a {drink}, what a fine taste!"
def is_empty(self: 'Glass') -> bool:
match self:
case Glass.Empty():
return True
return False
diet_coke = Glass.Full('diet coke')
empty = Glass.Empty()
print(diet_coke.report_drink())
print(empty.is_empty())
```
{{< /collapsable-explanation >}}
```
Ah a diet coke, what a fine taste!
True
```
2025-11-02 21:36:34 +05:30
## Closing thoughts
2025-11-03 08:21:38 +05:30
Those 6 lines are the bare minimum to get well organized and namespaced
algebraic enums in Python that are somewhat comparable to the ones in Rust. These
enums also play nicely with static type checkers, _goto-definitions_ will
lead you to the correct class definition.
2025-11-02 19:42:33 +05:30
2025-11-02 21:36:34 +05:30
I have packaged this decorator with a couple more typing restrictions into a library at [github:lavafroth/ape](https://github.com/lavafroth/ape).
2025-11-02 19:42:33 +05:30
2025-11-02 21:36:34 +05:30
I hope you enjoyed this foray into contorting Python.