Sum types — also called tagged unions or algebraic data types — are the feature I miss most when I switch from Rust or Haskell back to Python. The match statement landed in 3.10, but the standard library still does not give you a clean way to declare a closed set of variants where each variant carries its own fields.
Here is a 30-line metaclass that fixes that.
The result first
class Computation(metaclass=EnumMeta):
Nothing = Case()
To = Case(target=int)
List = Case(targets=list[int])
follower = Computation.List([1])
match follower:
case Computation.To(target=p):
print(p)
case Computation.List(targets=p):
print(p)
case Computation.Nothing:
print("nothing")
Three variants. Each variant is its own type. Pattern matching destructures fields by name. No Union, no isinstance chains, no boilerplate constructors.
The whole implementation
from dataclasses import make_dataclass, fields
class Case:
def __init__(self, **attributes):
self.dict = attributes
class EnumMeta(type):
def __new__(cls, name, bases, clsdict):
new_cls = super().__new__(cls, name, bases, clsdict)
for field_name, value in clsdict.items():
if not isinstance(value, Case):
continue
dc = make_dataclass(
f"{name}.{field_name}",
list(value.dict.items()),
bases=(new_cls,),
)
dc.__match_args__ = tuple(f.name for f in fields(dc))
setattr(new_cls, field_name, dc)
return new_cls
What is happening
Case is a placeholder. It records the fields a variant will carry and nothing else. Case(target=int) means this variant has one field named target typed as int.
EnumMeta walks the class body when the class is constructed. For every Case it finds, it does four things.
-
Builds a dataclass for that variant with
make_dataclass. The fields come straight from theCasekwargs. -
Inherits from the parent class.
bases=(new_cls,)meansComputation.Tois a subclass ofComputation. This is what makesmatch Computation.To(...)work as a class pattern. -
Sets
__match_args__. This is the magic line. Thematchstatement uses__match_args__to know which positional fields to destructure. Dataclasses do not get this in the right shape by default for keyword-style patterns, so we set it explicitly from the field names. -
Replaces the
Caseplaceholder on the class with the new dataclass.
After EnumMeta runs, Computation.List is no longer a Case — it is a real dataclass type. Calling Computation.List([1]) constructs an instance with targets=[1].
Why this beats the alternatives
Enum cannot carry per-variant fields. You would end up smuggling data through value tuples and losing type information.
Union[A, B, C] of dataclasses works for pattern matching, but you have to declare each variant as a separate top-level class and then wire them into a union by hand. The variants live everywhere; the union is a comment.
Libraries like returns or pyrsistent give you sum types but pull in a dependency and an opinionated style.
The metaclass approach keeps variants grouped under the parent type, so Computation is a closed namespace. You read the class definition and you see every possible value the type can take. That is the property that makes ADTs useful: exhaustiveness in one place.
Caveats
This is not exhaustive checking at the type level. mypy does not know Computation is closed, so a missing case in your match will not be flagged. If you want that, add a case _: assert_never(x) arm at the end.
make_dataclass does not accept forward references the way a typed dataclass body does. Stick to concrete types in Case(...) or pass strings and let dataclasses resolve them.
The variants are subclasses of the parent. That is load-bearing for match, but it also means isinstance(x, Computation) returns True for any variant, which you usually want.
When to reach for this
When you have a small, closed set of states that each carry different data. Parser results. State machine transitions. Validation outcomes. Anywhere you would write a chain of isinstance checks today.
For two states or a state without data, just use a dataclass with an Optional. For four or more variants with distinct payloads, the metaclass earns its keep.
Thirty lines. No dependencies. Real pattern matching.
Top comments (0)