Type shapes
This library leverages PolyType as a source generator that provides fast startup time and a consistent set of attributes that may be used for many purposes within your application.
Recommended configuration
PolyType is trim-safe and NativeAOT ready, particularly when used in its recommended configuration, where you apply GenerateShapeAttribute on the root type of your data model.
[GenerateShape]
public partial record Tree(string Name, Fruit[] Fruits);
public record Fruit(double Weight, string Color);
Witness classes
If you need to directly serialize a type that isn't declared in your project and is not annotated with GenerateShapeAttribute, you can define another class in your own project to provide that shape. Doing so leads to default serialization rules being applied to the type (e.g. only public members are serialized).
For this example, suppose you consume a FamilyTree
type from a library that you don't control and did not annotate their type for serialization.
In your own project, you can define this witness type and use it to serialize an external type.
// This class declared in another assembly, unattributed and outside of your control.
public class FamilyTree
{
}
// Within your own assembly, define a 'witness' class with one or more shapes generated for external types.
[GenerateShapeFor<FamilyTree>]
partial class Witness;
void Serialize()
{
var familyTree = new FamilyTree();
var serializer = new MessagePackSerializer();
// Serialize the FamilyTree instance using the shape generated from your witness class.
byte[] msgpack = serializer.Serialize<FamilyTree, Witness>(familyTree);
}
Note the only special bit is providing the Witness
class as a type argument to the Serialize
method.
The name of the witness class is completely inconsequential.
A witness class may have any number of GenerateShapeForAttribute<T> attributes on it.
It is typical (but not required) for an assembly to have at most one witness class, with all the external types listed on it that you need to serialize as top-level objects.
You do not need a witness class for an external type to reference that type from a graph that is already rooted in a type that is attributed.
Fallback configuration
In the unlikely event that you have a need to serialize a type that does not have a shape source-generated for it, you can use the conventional reflection approach of serialization with Nerdbank.MessagePack, if you do not need to run in a trimmed app.
void SerializeUnshapedType()
{
Person person = new("Andrew", "Arnott");
MessagePackSerializer serializer = new();
byte[] msgpack = serializer.Serialize(person, ReflectionTypeShapeProvider.Default);
Person? deserialized = serializer.Deserialize<Person>(msgpack, ReflectionTypeShapeProvider.Default);
}
record Person(string FirstName, string LastName);
Source generated data models
If your data models are themselves declared by a source generator, the PolyType source generator will be unable to emit type shapes for your data models in the same compilation. Using the ReflectionTypeShapeProvider.Default is one way you can workaround this, at the cost of somewhat slower serialization (especially the first time due to reflection), but this does not always work in a trimmed application. And it adds some limitations to what types can be serialized in a NativeAOT application where dynamic code cannot run.
Instead of falling back to reflection, you can still use the PolyType source generator by declaring your data types in another assembly that your serialization code then references. For example, if assembly "A" declares your data types via a source generator (e.g. Vogen), assembly "B" can reference "A", and then use a Witness type (described above) to source generate the type shapes for all your data types.
Still another option to get your source generated data type to be serializable may be to define a marshaler to a surrogate type.
Working with Vogen
Vogen is a source generator that wraps primitive types in custom structs that can add validation and another level of type safety to your data models.
If you take the approach described above where your Vogen data models are declared in another project, your serialization of these data models might look like this:
partial class VogenConsumer
{
static MessagePackSerializer serializer = new();
#pragma warning disable NBMsgPack051 // We cannot use the type constraint overload because Vogen requires that we use Witness types.
void Serialize(Customer customer)
{
byte[] msgpack = serializer.Serialize(customer, Witness.ShapeProvider);
}
[GenerateShapeFor<Customer>]
partial class Witness;
}
While the above approach is simple, the outcome will be less efficient msgpack output, given each of these strongly typed wrappers will serialize as a compound value with only one property instead of just serializing the property value itself. For example, if we render the serialized msgpack as JSON, we'll see this:
{
"Id": {
"Value": 123
},
"Name": "Some Person"
}
Obviously that is not ideal. We would instead prefer to see this:
{
"Id": 123,
"Name": "Some Person"
}
We can achieve this, and without requiring that the data types be moved to another project, by using marshalers. First, define the data model like this:
[ValueObject<int>]
[TypeShape(Marshaler = typeof(Marshaler), Kind = TypeShapeKind.None)]
public partial struct CustomerId
{
[EditorBrowsable(EditorBrowsableState.Never)]
public class Marshaler : IMarshaler<CustomerId, int>
{
int IMarshaler<CustomerId, int>.Marshal(CustomerId value) => value.Value;
CustomerId IMarshaler<CustomerId, int>.Unmarshal(int value) => From(value);
}
}
[GenerateShape]
public partial record Customer
{
public required CustomerId Id { get; set; }
public required string Name { get; set; }
}
Note the TypeShapeAttribute.Marshaler that we apply to the CustomerId
struct, and the simple marshaler that we define.
We can then serialize the data model and get the desired output schema, using code like this:
class VogenConsumer
{
static MessagePackSerializer serializer = new();
void Serialize(Customer customer)
{
byte[] msgpack = serializer.Serialize(customer);
}
}