Skip to content
English

Custom serializers

For formats beyond JSON / CBOR there are two routes:

  • Avro and Protobuf ship with the frameworkAvroSerializer and ProtobufSerializer take a compiled schema you bring and handle the wiring.
  • Anything else — MessagePack, FlatBuffers, a bespoke format — is an implementation of the Serializer<T> interface, bound to specific message classes.
import { Serializer, SerializationExtensionId } from 'actor-ts';
class ProtoOrderSerializer implements Serializer<Order> {
readonly id = 100;
readonly name = 'order-protobuf';
readonly includesManifest = false;
manifest(_obj: Order): string { return ''; }
toBinary(obj: Order): Uint8Array {
return OrderProto.encode(obj).finish();
}
fromBinary(bytes: Uint8Array, _manifest: string): Order {
return OrderProto.decode(bytes);
}
}
// Wire it up:
const ext = system.extension(SerializationExtensionId);
const orderSerializer = new ProtoOrderSerializer();
ext.register(orderSerializer); // add it to the id lookup table
ext.bind(Order, orderSerializer.id); // route Order through it (id 100)

Now the extension resolves every Order value to Protobuf; every other value falls back to the default serializer (JSON).

interface Serializer<T = unknown> {
readonly id: number;
readonly name: string;
includesManifest: boolean;
manifest(obj: T): string;
toBinary(obj: T): Uint8Array;
fromBinary(bytes: Uint8Array, manifest: string): T;
}
  • id — unique number ≥ 100 (1-99 reserved for built-ins). Embedded in every frame; tells the receiver which serializer to use.
  • name — diagnostic-only.
  • includesManifest — whether manifest() returns useful info.
  • manifest(obj) — returns a string identifying the concrete type. Use it when one serializer handles multiple types and the decoder needs to know which.
  • toBinary / fromBinary — the actual encode / decode.
ext.register(orderSerializer);
ext.register(paymentSerializer);
ext.register(cancelSerializer);
ext.bind(Order, orderSerializer.id);
ext.bind(Payment, paymentSerializer.id);
ext.bind(Cancellation, cancelSerializer.id);

The extension matches values to serializers by constructor: value instanceof Class → use that class’s bound serializer.

This means:

  • Plain objects don’t match any binding → fall back to default (JSON or CBOR).
  • Class instances match their class binding.
  • Subclasses match the parent’s binding (since instanceof is transitive).

Three good fits:

  1. Cross-language interop — your actor system needs to talk to non-JS services using Protobuf / Avro / similar.
  2. Schema-enforced evolution — Protobuf’s schema files in source control are the canonical types; the framework reads from them.
  3. Specific perf / size requirements — FlatBuffers for zero-copy reads, MessagePack for size between JSON + CBOR.

For typical apps without these constraints, JSON or CBOR suffices.

Both formats ship as ready-made serializers. What you bring is the compiled schema; the schema library stays a dependency of your project, exactly as zodCodec takes a schema rather than importing zod. actor-ts never imports avsc or protobufjs — it accepts anything with the right shape, so generated static code (pbjs, ts-proto) fits too.

import avsc from 'avsc';
import { AvroSerializer, AvroSerializerOptions } from 'actor-ts';
const avroType = avsc.Type.forSchema({
name: 'Deposited',
type: 'record',
fields: [
{ name: 'amount', type: 'int' },
{ name: 'currency', type: 'string' },
],
});
const avroOptions = AvroSerializerOptions.create<Deposited>()
.withAvroType(avroType)
.withId(100);
const avroSerializer = new AvroSerializer(avroOptions);
import protobuf from 'protobufjs';
import { ProtobufSerializer, ProtobufSerializerOptions } from 'actor-ts';
const root = protobuf.parse(`
syntax = "proto3";
package shop;
message Order { string id = 1; int32 amount = 2; }
`).root;
const protobufOptions = ProtobufSerializerOptions.create<Order>()
.withMessageType(root.lookupType('shop.Order'))
.withId(101);
const protobufSerializer = new ProtobufSerializer(protobufOptions);

A plain options object works identically — new AvroSerializer({ avroType, id: 100 }).

Each one is a thin wrapper, but the thin parts are the ones that bite:

  • avsc cannot decode a plain Uint8Array. It reaches for Buffer-private methods inside fromBuffer, and base64 framing hands it exactly a plain Uint8Array — so a hand-rolled Avro serializer encodes fine and fails on replay, on Bun, Node and Deno alike. AvroSerializer bridges that.
  • protobufjs returns pooled bytes. Writer.finish() is a window into a shared write pool (offset ~28 KB into a 64 KB pool on Node in one measurement). Stored as-is, the row is a view onto other messages’ memory. ProtobufSerializer detaches it.
  • protobufjs does not validate on encode. verify() is a separate call; the serializer makes it, so a wrong-typed field is a write-time error naming the field instead of a puzzle later.
  • Wrong-schema bytes usually decode “successfully”. Avro carries no field tags and Protobuf carries no message name, so foreign bytes turn into plausible nonsense. Both serializers write a manifest (defaulted from the record name / fully-qualified name) and refuse a payload written under a different one.
  • Protobuf decodes to a plain object. toObject with defaults filled in and 64-bit fields as strings, because a Message instance and a Long are not things a journal can store. Opt out with .withPlainObjects(false).

id is required and must be ≥ 100 — 1–99 belong to the built-ins (JSON = 1, CBOR = 2), and the constructor rejects anything lower. The id is a wire contract embedded in every stored row: change it and old rows stop decoding.

Loading .proto at runtime would mean filesystem access — unavailable in a browser, permission-gated on Deno, and one more thing to ship next to the bundle. Taking the compiled message type leaves the choice with you, and none of the three routes force a build step:

protobuf.parse(protoSource).root.lookupType('Order') // a source string
protobuf.Root.fromJSON(descriptor).lookupType('Order') // a bundled descriptor
import { Order } from './generated/order.js'; // generated static code

Hand-rolled Protobuf, when you need more control

Section titled “Hand-rolled Protobuf, when you need more control”

ProtobufSerializer writes one fixed manifest. When the manifest has to be computed per value — a version tag read off the message, say — implement the interface directly:

import { Serializer } from 'actor-ts';
import { Order } from './generated/order_pb.js';
class OrderProtoSerializer implements Serializer<Order> {
readonly id = 100;
readonly name = 'order-pb';
readonly includesManifest = true;
manifest(obj: Order): string {
return `order.v${obj.getVersion()}`;
}
toBinary(obj: Order): Uint8Array {
return obj.serializeBinary();
}
fromBinary(bytes: Uint8Array, manifest: string): Order {
const order = Order.deserializeBinary(bytes);
if (manifest && manifest !== `order.v${order.getVersion()}`) {
throw new Error(`version mismatch: ${manifest} vs v${order.getVersion()}`);
}
return order;
}
}
const orderSerializer = new OrderProtoSerializer();
ext.register(orderSerializer);
ext.bind(Order, orderSerializer.id);

The manifest carries version info; the decoder verifies. Combined with Protobuf’s wire-level backward compatibility, you can evolve schemas while old messages decode correctly.

import { encode, decode } from '@msgpack/msgpack';
class MessagePackSerializer implements Serializer<unknown> {
readonly id = 101;
readonly name = 'msgpack';
readonly includesManifest = false;
manifest(): string { return ''; }
toBinary(obj: unknown): Uint8Array { return encode(obj); }
fromBinary(bytes: Uint8Array): unknown { return decode(bytes); }
}
// As a system-wide default:
ext.setDefault(new MessagePackSerializer());

MessagePack sits between JSON (size, parse speed) and Protobuf (typed schemas). Useful when you want a CBOR-like format with broader cross-language libraries.

Every journal, snapshot store and durable-state store accepts a serializer option — the way a custom serializer reaches stored rows. The store frames each row self-describingly ({"__serialized__":{"id":…,"manifest":…,"data":"<base64>"}}), so rows written before the serializer was configured keep decoding through the default tagged-JSON codec, and both formats can coexist in one stream:

const journalOptions = SqliteJournalOptions.create()
.withPath('./journal.db')
.withSerializer(new MessagePackSerializer());
const journal = new SqliteJournal(journalOptions);

The Register<X>Plugins bundles take a shared withSerializer(...) that fans out to all of the backend’s stores (a leaf’s own serializer wins). Two rules to plan around:

  • Reading a framed row requires a serializer with the same id — remove or swap the serializer and reads of old framed rows fail with a SerializationError naming the id they need.
  • The in-memory stores ignore the option: their round-trip always uses the default codec, which is stricter than a custom serializer can be — the safe direction for a dev/test default.

withSerializer is a store-wide setting: one format for every payload the store writes. When different versions of one event need different formats — a v1 already on disk in Avro, a v2 you want in Protobuf — wrap the serializer as a codec and register it per version with the SchemaRegistry:

import { serializerCodec } from 'actor-ts';
registry.register('BankAccount.Deposited', 1, {
codec: serializerCodec(avroSerializer),
});
registry.register('BankAccount.Deposited', 2, {
codec: serializerCodec(protobufSerializer),
upcastFromPrev: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }),
});

Each format still exists once, as a Serializer; serializerCodec only adapts it to the registry’s per-version slot.

ext.register(s); ext.bind(Class, s.id) → route only that class through s
ext.setDefault(s) → the default the extension uses for unbound values

Per-class is safer — keeps the default fallback for things you didn’t think to bind. System-wide is uniform — every unbound value goes through the same serializer.

For most apps: per-class for specific types (Protobuf’d events), JSON default for everything else.

Custom serializers should handle forward + backward compatibility:

fromBinary(bytes: Uint8Array, manifest: string): Order {
if (manifest === 'order.v1') {
return migrateV1ToV2(Order.deserializeBinary(bytes));
}
return Order.deserializeBinary(bytes);
}

For Protobuf, the wire format itself is forward-compatible (new fields ignored by old code). You only need explicit migration when the semantic changes (renames, splits) — handled by your custom serializer the same way the framework’s migration adapters handle JSON-format evolution.

const ext = system.extension(SerializationExtensionId);
// 1. Register each serializer (adds it to the id lookup table):
ext.register(orderSerializer);
ext.register(paymentSerializer);
// 2. Bind classes to a registered serializer's id (adds routing):
ext.bind(Order, orderSerializer.id);
ext.bind(Payment, paymentSerializer.id);

register adds a serializer to the id lookup table (so the decoder can find it by id, and so bind can reference it). bind then adds routing — mapping a class to an already-registered serializer’s id. You must register before you bind; binding an unregistered id throws.