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