Skip to content

Typed messages

Plain dicts work everywhere in lapinbeam, but you rarely want msg.get("type") == "ACK" sprinkled through actor code when Python already has real types. Two independent pieces make this comfortable: type-preserving payloads (dataclasses and Pydantic models survive the trip across nodes) and typed dispatch (@on(Type), an alternative to a single receive method).

Type-preserving payloads

lapinbeam.codec wraps @dataclass instances and Pydantic v2 models in a tagged envelope ({"__lb_type__": "module.QualName", "data": {...}}) before they hit the JSON-only Rust transport, and rebuilds the exact type on the receiving end:

from dataclasses import dataclass
from lapinbeam import Node, Supervisor, actor


@dataclass
class Task:
    payload_id: int
    name: str


@actor(name="worker")
class Worker:
    async def receive(self, msg: Task):
        print(msg.payload_id, msg.name)  # msg is a real Task, not a dict


# Sending is transparent — no manual serialization step:
# await remote.send(Task(payload_id=1, name="build"))

This works the same for Pydantic v2 models. Custom classes (anything that isn't a dataclass or a Pydantic model) need an explicit codec:

from lapinbeam import register_codec

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

register_codec(
    Point,
    encode=lambda p: {"x": p.x, "y": p.y},
    decode=lambda d: Point(d["x"], d["y"]),
)

Both ends of the cluster must register the same codec — the type is looked up by its module.QualName tag, so the class needs to be importable (or registered) wherever decode_payload runs.

Local sends are always zero-copy

Type preservation only matters for remote sends. A local ActorRef.send(obj) passes the exact Python object by reference — no encoding, no copy, and no restriction to JSON-compatible or codec-covered types. You can send anything between two actors on the same node.

Typed dispatch with @on

Once messages carry real types, dispatching on them by hand still means a chain of isinstance/match checks inside one receive. @on(Type) moves that into the actor's declaration:

from dataclasses import dataclass
from lapinbeam import actor, on


@dataclass
class Task:
    payload_id: int
    name: str


@dataclass
class Ack:
    result: int


@actor(name="worker")
class Worker:
    @on(Task)
    async def handle_task(self, msg: Task):
        ...

    @on(Ack)
    async def handle_ack(self, msg: Ack):
        ...

    @on(default=True)
    async def handle_other(self, msg):
        print("unrecognized message:", msg)

An actor with any @on handler stops using receive entirely — messages are dispatched by type(msg) to the matching handler. @on(default=True) marks a single catch-all handler for any type without a dedicated one (including plain dicts); it is the simplest way to stay safe against message shapes you didn't plan for. Without a default handler, an unmatched type raises TypeError, which crashes the actor and lets Supervisor restart it — the same "let it crash" philosophy the rest of the framework already follows for unhandled exceptions in receive.

Actors that only define receive are completely unaffected — @on is strictly additive, not a migration you're forced into.

Or skip @on entirely: use match

Since the message already arrives with its real type, you don't need @on at all to get typed dispatch — Python's structural pattern matching works directly on dataclasses inside a single receive:

async def receive(self, msg):
    match msg:
        case Task(payload_id=pid, name=name):
            ...
        case Ack(result=r):
            ...
        case _:
            print("unrecognized message:", msg)

This costs nothing (no framework feature involved) and gives you destructuring in the same breath as the type check. Reach for @on instead when you'd rather have one small method per message type — e.g. to unit-test each handler in isolation, or once a single match block grows unwieldy.