DEV Community

Cover image for I Built Rust-Style ADTs in 30 Lines of Python (Pattern Matching Works)
Alexander Mia
Alexander Mia

Posted on

I Built Rust-Style ADTs in 30 Lines of Python (Pattern Matching Works)

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. Builds a dataclass for that variant with make_dataclass. The fields come straight from the Case kwargs.
  2. Inherits from the parent class. bases=(new_cls,) means Computation.To is a subclass of Computation. This is what makes match Computation.To(...) work as a class pattern.
  3. Sets __match_args__. This is the magic line. The match statement 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.
  4. Replaces the Case placeholder 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)