Skip to main content
Version: dev

External-Type Serialization

External-type serialization generates a Fory struct serializer for a class owned by another Dart library or package. Define a local serializer declaration with the same fields and name the external class with ForyStruct.target.

Define an External Structural Serializer

Suppose a dependency owns this class:

final class User {
const User({
required this.name,
required this.age,
});

final String name;
final int age;
}

Add the serializer declaration in your package:

import 'package:fory/fory.dart';
import 'package:third_party/models.dart' as third_party;

part 'external_serializers.fory.dart';

(target: third_party.User)
abstract final class UserSerializer {
(id: 1)
late final String name;

(id: 2, type: Int32Type())
late final int age;
}

The declaration must be abstract final, cannot have type parameters, and must declare each schema field as late final without an initializer. Each serialized field name and Dart type, including nullability and generic arguments, must exactly match an accessible getter on the target.

The declaration's field list is the complete external schema. Fory does not automatically add fields from the target, its superclass, or its mixins. You may explicitly declare an accessible inherited target property just like any other schema field. exposePrivateFields and ignoreInheritedPrivateFields apply only to ordinary Dart hierarchies and are invalid with ForyStruct.target.

Public target fields count toward the graph-memory budget automatically, but that accounting does not add them to the serialized schema. Use @ForyField(ignore: true) on an extra declaration field to count other storage without serializing it.

Run the generator as usual:

dart run build_runner build

Register and Use the Target

Register the external target through the generated module:

final fory = Fory();
ExternalSerializersForyModule.register(
fory,
third_party.User,
name: 'example.User',
);

final bytes = fory.serialize(
const third_party.User(name: 'Ada', age: 36),
);
final user = fory.deserialize<third_party.User>(bytes);

Numeric IDs work the same way:

ExternalSerializersForyModule.register(
fory,
third_party.User,
id: 100,
);

Register the target third_party.User, not UserSerializer.

Constructors and Mutable Targets

Without a constructor option, generation uses the target's public unnamed generative constructor. Constructor parameters map to schema fields by name and must use exactly the same Dart types.

For example, suppose the dependency exposes this immutable class:

final class Money {
const Money.fromParts({
required this.currency,
required this.units,
});

final String currency;
final int units;
}

Select its public named generative constructor in the serializer declaration:

(
target: third_party.Money,
constructor: 'fromParts',
)
abstract final class MoneySerializer {
late final String currency;

(type: Int64Type())
late final int units;
}

After decoding the fields, the generated serializer calls third_party.Money.fromParts(currency: ..., units: ...).

For a mutable target, a public generative constructor with no required arguments plus matching setters allows Fory to construct the target first and then assign its fields. This also supports cycles when reference tracking is enabled. For example, suppose the dependency exposes:

final class Node {
Node.empty();

late String label;
Node? next;
}

Select Node.empty in the serializer declaration:

(
target: third_party.Node,
constructor: 'empty',
)
abstract final class NodeSerializer {
late final String label;

(ref: true)
late final third_party.Node? next;
}

The generated serializer can call third_party.Node.empty(), publish the new node for reference tracking, and then assign label and next.

A constructor-based target cannot decode a statically known reference-tracked path back to itself because the target does not exist until its constructor arguments have been read. This includes the target nested as a List or Set element or as a Map key or value. Generation rejects these schemas. Use a mutable two-phase target or a custom serializer when cycles are required. Indirect cycles that cannot be determined from the declaration are also unsupported for constructor-based targets.

Factory constructors, private constructors, abstract targets, external enums, external unions, records, extension types, and built-in collection types are not external struct targets.

Fields and Collections

After registration, the target works anywhere an ordinary registered struct works. A containing generated class does not select the serializer declaration:

()
final class Group {
Group();

third_party.User? owner;

(element: DeclaredType())
List<third_party.User> users = <third_party.User>[];

(value: DeclaredType())
Map<String, third_party.User> usersByName =
<String, third_party.User>{};
}

Nested lists, sets, and maps resolve the registered target recursively. Dynamic fields and heterogeneous collections also resolve each concrete target by its registered type:

(dynamic: true)
Object? value;

Register every concrete external type that can appear dynamically.

Non-empty root lists, sets, and maps decode their elements, keys, and values as the registered external targets. Dart root collections retain their existing runtime shapes, so read a root collection as Object? and cast its outer carrier:

final decoded =
fory.deserialize<Object?>(
fory.serialize(<third_party.User>[user]),
)
as List<Object?>;
final first = decoded.first as third_party.User;

An empty root collection contains no element type identity.

Closed Generic Targets

Suppose the dependency exposes this generic class:

final class Box<T> {
const Box(this.value);

final T value;
}

Box<T> is open because T is unresolved. Supplying a concrete type argument, such as Box<String>, produces a closed generic instantiation that code generation can analyze:

(target: third_party.Box<String>)
abstract final class StringBoxSerializer {
late final String value;
}

The generated serializer reconstructs values with third_party.Box<String>(...) and applies only to Box<String>. Register and use that exact target type directly:

final fory = Fory();
ExternalSerializersForyModule.register(
fory,
third_party.Box<String>,
id: 102,
);

final input = const third_party.Box<String>('hello');
final bytes = fory.serialize(input);
final output = fory.deserialize<third_party.Box<String>>(bytes);

print(output.value); // hello

Register third_party.Box<String>, not StringBoxSerializer. A different instantiation such as Box<int> needs its own serializer declaration and registration; one declaration does not cover every Box<T>.

Schema Evolution

evolving and field IDs work exactly as they do for an ordinary generated struct:

(
target: third_party.User,
evolving: true,
)
abstract final class UserSerializer {
(id: 1)
late final String name;
}

Keep field IDs and the registered type identity stable across peers. A field name must still match the corresponding property on the local target class.

When to Use a Custom Serializer

Use a custom serializer when the target needs a custom wire body, field-name translation, value conversion, factory-only construction, private state, or any reconstruction rule that cannot be expressed through matching public getters, constructor parameters, and setters.