Table of Contents

Unions

Polymorphic serialization

You can serialize instances of certain types derived from the declared type and deserialize them back to their original runtime types using the KnownSubTypeAttribute<TSubType>.

For instance, suppose you have this type to serialize:

public class Farm
{
    public List<Animal>? Animals { get; set; }
}

But there are many kinds of animals. You can get them to serialize and deserialize correctly like this:

[KnownSubType<Cow>(1)]
[KnownSubType<Horse>(2)]
[KnownSubType<Dog>(3)]
public class Animal
{
    public string? Name { get; set; }
}

[GenerateShape]
public partial class Cow : Animal { }
[GenerateShape]
public partial class Horse : Animal { }
[GenerateShape]
public partial class Dog : Animal { }

This changes the schema of the serialized data to include a tag that indicates the type of the object.

Without any KnownSubTypeAttribute<TSubType>, an Animal object would serialize like this (as represented in JSON):

{ "Name": "Bessie" }

But with the KnownSubTypeAttribute, it serializes like this:

[null, { "Name": "Bessie" }]

See how the natural form of Animal is still there, but nested as the second element in a 2-element array. The null first element indicates that the object was literally Animal (rather than a derived type). If the serialized object were an instance of Cow, the first element would be 1 instead of null:

[1, { "Name": "Bessie" }]

This special union schema is only used when the statically declared type is a class that has KnownSubTypeAttribute on it. It is not used when the derived type is statically known. For example, consider this collection of horses:

public class HorsePen
{
    public List<Horse>? Horses { get; set; }
}

This would serialize like this:

{ "Horses": [{ "Name": "Bessie" }] }

Note the lack of the union schema that would add [2, ... ] around every horse. This is because the Horse type is statically known as the generic type argument of the collection, and there's no need to add serialized data to indicate the runtime type.

Now suppose you have different breeds of horses that each had their own subtype:

[KnownSubType<QuarterHorse>(1)]
[KnownSubType<Thoroughbred>(2)]
public partial class Horse : Animal { }

[GenerateShape]
public partial class QuarterHorse : Horse { }
[GenerateShape]
public partial class Thoroughbred : Horse { }

At this point your HorsePen would serialize with the union schema around each horse:

{ "Horses": [[1, { "Name": "Bessie" }], [2, { "Name", "Lightfoot" }]] }

But now let's consider your Farm class, which has a collection of Animal objects. The Animal class only knows about Horse as a subtype and designates 2 as the alias for that subtype. Animal has no designation for QuarterHorse or Thoroughbred. As such, serializing your Farm would drop any details about horse breeds and deserializing would produce Horse objects, not QuarterHorse or Thoroughbred. To fix this, you would need to add KnownSubTypeAttribute<TSubType> to the Animal class for QuarterHorse and Thoroughbred that assigns type aliases for each of them.

Alias types

An alias may be an integer or a string. String aliases are case sensitive.

Aliases may also be inferred from the Type.FullName of the sub-type, in which case they are treated as strings.

The following example shows using strings:

[GenerateShape]
[KnownSubType<Horse>("Horse")]
[KnownSubType<Cow>("Cow")]
partial class Animal
{
    public string? Name { get; set; }
}

[GenerateShape]
partial class Horse : Animal { }

[GenerateShape]
partial class Cow : Animal { }

Mixing alias types for a given base type is allowed, as shown here:

[GenerateShape]
[KnownSubType<Horse>(1)]
[KnownSubType<Cow>("Cow")]
partial class Animal
{
    public string? Name { get; set; }
}

[GenerateShape]
partial class Horse : Animal { }

[GenerateShape]
partial class Cow : Animal { }

Following is an example of string alias inferrence:

[GenerateShape]
[KnownSubType<Horse>]
[KnownSubType<Cow>]
partial class Animal
{
    public string? Name { get; set; }
}

[GenerateShape]
partial class Horse : Animal { }

[GenerateShape]
partial class Cow : Animal { }

Note that while inferrence is the simplest syntax, it results in the serialized schema including the full name of the type, which can make the serialized form more fragile in the face of refactoring changes. It can also result in a poorer experience if the data is exchanged with non-.NET programs.

Nested sub-types

Suppose you had the following type hierarchy:

Animal <- Horse <- Quarterback

The Animal class must have the whole set of transitive derived types listed as known sub-types directly on itself. It will not do for Animal to merely mention Horse and for Horse to listed Quarterback as a sub-type, as this is not currently supported.

Generic sub-types

Sub-types may be generic types, but they must be closed generic types (i.e. all the generic type arguments must be specified). You may close the generic type several times, assigning a unique alias to each one.

Generic sub-types require a witness class to provide their type shape. This witness type must be specified as a second type argument to KnownSubTypeAttribute<TSubType, TShapeProvider>.

For example:

[KnownSubType<Horse>(1)]
[KnownSubType<Cow<SolidHoof>, Witness>(2)]
[KnownSubType<Cow<ClovenHoof>, Witness>(3)]
class Animal
{
    public string? Name { get; set; }
}

[GenerateShape]
partial class Horse : Animal { }

partial class Cow<THoof> : Animal { }

[GenerateShape<Cow<SolidHoof>>]
[GenerateShape<Cow<ClovenHoof>>]
partial class Witness;

class SolidHoof { }

class ClovenHoof { }

Runtime subtype registration

Static registration via attributes is not always possible. For instance, you may want to serialize types from a third-party library that you cannot modify. Or you may have an extensible plugin system where new types are added at runtime. Or most simply, the derived types may not be declared in the same assembly as the base type.

In such cases, runtime registration of subtypes is possible to allow you to run any custom logic you may require to discover and register subtypes. Your code is still responsible to ensure unique aliases are assigned to each subtype.

Consider the following example where a type hierarchy is registered without using the attribute approach:

class Animal
{
    public string? Name { get; set; }
}

[GenerateShape]
partial class Horse : Animal { }

[GenerateShape]
partial class Cow : Animal { }

class SerializationConfigurator
{
    internal void ConfigureAnimalsMapping(MessagePackSerializer serializer)
    {
        KnownSubTypeMapping<Animal> mapping = new();
        mapping.Add<Horse>(1);
        mapping.Add<Cow>(2);

        serializer.RegisterKnownSubTypes(mapping);
    }
}