Table of Contents

Surrogate types

While using the GenerateShapeAttribute is by far the simplest way to make an entire type graph serializable, some types may not be compatible with automatic serialization. In such cases, you can define a surrogate type that is serializable and a marshaler that can convert between the two types.

Surrogate types are an easier way to make an unserializable type serializable than writing custom converters. Surrogate types can also effectively assist with the structural equality feature for types that may not be directly comparable.

Suppose you have the following type, which has fields that are not directly serializable. This could be because the fields are of a type that cannot be directly serialized. In this sample they are private fields which are not serialized by default (though they could be with an attribute, but we're ignoring that for purposes of this sample).

[GenerateShape]
public partial class OriginalType
{
    private int a;
    private int b;

    public OriginalType(int a, int b)
    {
        this.a = a;
        this.b = b;
    }

    public int Sum => this.a + this.b;
}

To serialize this type, we'll use a surrogate that does expose these fields for serialization.

Write a surrogate type

Surrogate types should generally be structs to avoid allocation costs from these temporary conversions. They can be quite simple, containing only the properties necessary to enable automatic serialization. This record struct is the simplest syntax for expressing public properties for serialization. We've chosen properties that correspond to the fields in the previous sample that require serialization for the surrogate type defined here.

internal record struct MarshaledType(int A, int B);

The surrogate must have at least internal visibility.

Write a marshaler

Now we need to define a simple marshaler that can copy the data from the non-serializable type to its surrogate, and back again. The marshaler implements IMarshaller<T, TSurrogate>.

Important

When the original type is a reference type and the surrogate type is a value type, make sure to specify a nullable surrogate type so that your marshaler can retain the null identity properly.

When this marshaler is nested within the original type, C# gives it access to the containing type's private fields, which is useful for this sample.

internal class MyTypeMarshaller : IMarshaller<OriginalType, MarshaledType?>
{
    public MarshaledType? ToSurrogate(OriginalType? value)
        => value is null ? null : new(value.a, value.b);

    public OriginalType? FromSurrogate(MarshaledType? surrogate)
        => surrogate.HasValue ? new(surrogate.Value.A, surrogate.Value.B) : null;
}

The marshaler must have at least internal visibility.

This marshaler must be referenced via TypeShapeAttribute.Marshaller on an attribute applied to the original type.

Sample

Taken together with the added TypeShapeAttribute that refers to the marshaler, we have the following complete sample:

[GenerateShape]
[TypeShape(Marshaller = typeof(MyTypeMarshaller))]
public partial class OriginalType
{
    private int a;
    private int b;

    public OriginalType(int a, int b)
    {
        this.a = a;
        this.b = b;
    }

    public int Sum => this.a + this.b;

    internal record struct MarshaledType(int A, int B);

    internal class MyTypeMarshaller : IMarshaller<OriginalType, MarshaledType?>
    {
        public MarshaledType? ToSurrogate(OriginalType? value)
            => value is null ? null : new(value.a, value.b);

        public OriginalType? FromSurrogate(MarshaledType? surrogate)
            => surrogate.HasValue ? new(surrogate.Value.A, surrogate.Value.B) : null;
    }
}

Open generic data type

Open generic data types can define surrogates for themselves as well. Just take care to use the generic type definition syntax (no type arguments specified) when referencing the surrogate type.

[TypeShape(Marshaller = typeof(OpenGenericDataType<>.Marshaller))]
internal class OpenGenericDataType<T>
{
    public T? Value { get; set; }

    internal record struct MarshaledType(T? Value);

    internal class Marshaller : IMarshaller<OpenGenericDataType<T>, MarshaledType?>
    {
        public OpenGenericDataType<T>? FromSurrogate(MarshaledType? surrogate)
            => surrogate.HasValue ? new() { Value = surrogate.Value.Value } : null;

        public MarshaledType? ToSurrogate(OpenGenericDataType<T>? value)
            => value is null ? null : new(value.Value);
    }
}

While the GenerateShapeAttribute cannot be applied to an open generic data type, this data type can be closed and used from another data structure. It can also be used as the top-level structure by closing the generic on a Witness class.

[GenerateShape<OpenGenericDataType<int>>]
internal partial class Witness;

void SerializeByWitness(OpenGenericDataType<int> value) => Serializer.Serialize<OpenGenericDataType<int>, Witness>(value);

private static readonly MessagePackSerializer Serializer = new();