custom-serializers.md

June 4, 2026 ยท View on GitHub

A custom serializer lets you control exactly how a type is encoded and decoded. You only need one when:

  • the type comes from a package you cannot modify and cannot be annotated with @ForyStruct()
  • you need a completely custom binary layout
  • you are implementing a union/discriminated type

For your own models, @ForyStruct() with code generation is almost always the better choice.

Implement Serializer<T>

Subclass Serializer<T> and implement write and read. Use context.buffer to read and write raw bytes:

import 'package:fory/fory.dart';

final class Person {
  Person(this.name, this.age);

  final String name;
  final int age;
}

final class PersonSerializer extends Serializer<Person> {
  const PersonSerializer();

  @override
  void write(WriteContext context, Person value) {
    final buffer = context.buffer;
    buffer.writeUtf8(value.name);
    buffer.writeInt64FromInt(value.age);
  }

  @override
  Person read(ReadContext context) {
    final buffer = context.buffer;
    return Person(buffer.readUtf8(), buffer.readInt64AsInt());
  }
}

Register the serializer before you use it:

final fory = Fory();
fory.registerSerializer(
  Person,
  const PersonSerializer(),
  name: 'example.Person',
);

Writing Nested Objects

When your serializer has a field that is itself a Fory-managed type, use context.writeRef and context.readRef rather than calling fory.serialize recursively. This keeps reference tracking correct and avoids writing a full root frame inside a nested payload.

@override
void write(WriteContext context, Wrapper value) {
  context.writeRef(value.child);
}

@override
Wrapper read(ReadContext context) {
  return Wrapper(context.readRef() as Child);
}

If you do not need reference identity tracking for a nested value (i.e., you know the value will never appear more than once in a graph), use writeNonRef:

context.writeNonRef(value.child);

Unions

For a discriminated/tagged union, extend UnionSerializer<T> instead of Serializer<T>. Write a discriminant value first, then the active variant; read the discriminant and dispatch accordingly.

final class ShapeSerializer extends UnionSerializer<Shape> {
  const ShapeSerializer();

  @override
  void write(WriteContext context, Shape value) {
    // write active variant
  }

  @override
  Shape read(ReadContext context) {
    // read discriminant, return correct variant
    throw UnimplementedError();
  }
}

Circular References in Custom Serializers

If your serializer can encounter circular object graphs, bind the object to the reference tracker before reading its nested fields:

final value = Node.empty();
context.reference(value);         // register the object first
value.next = context.readRef() as Node?;  // now nested reads can refer back to it
return value;

Skipping this step causes back-references to that object to resolve to null.

Tips

  • Use context.buffer for direct byte reads/writes in hot paths.
  • Register the serializer with the same identity (id or name) on every side.