How to Serialize Polymorphic Types in C#
A member declared as a base class or an interface can hold any derived type at run time. A binary serializer that writes only the declared type's members loses the derived members, and it cannot know which class to construct when it reads the bytes back. The stream has to say which concrete type follows, in a form that is compact and stable.
Sphere10 Framework handles this with a registry of type codes. SerializerFactory assigns each registered type a numeric code, and PolymorphicSerializer<TItem> writes the code of the concrete type before each item. This article shows how to declare the derived types, what is written to the stream, and what the reader needs in order to decode it.
1. Declare the derived types
Automatic assembly discovers types by walking members, and a member of type Shape does not reveal the classes that derive from it. The [KnownSubType<T>] attribute on the base class supplies them.
[KnownSubType<Circle>]
[KnownSubType<Rectangle>]
public abstract class Shape {
public string Name { get; set; }
}
public sealed class Circle : Shape {
public double Radius { get; set; }
}
public sealed class Rectangle : Shape {
public double Width { get; set; }
public double Height { get; set; }
}
The container holds the base type twice, once in a list and once as a single member.
public sealed class Canvas {
public List<Shape> Shapes { get; set; } = new();
public Shape Selected { get; set; }
}
The attributes are the only serialization detail in these classes. The Framework reads them when the types are registered, which a later section shows.
2. How polymorphic values are written
The factory wraps the serializer of a reference type in two layers. ReferenceSerializer<TItem> writes one prefix byte that marks a null, a new value or a back-reference to an object already written. PolymorphicSerializer<TItem> then identifies the serializer of the value's concrete type and delegates to it.
Diagram source
---
config:
packet:
bitsPerRow: 16
bitWidth: 22
showBits: false
---
packet-beta
0-5: "Prefix, 1 byte"
6-15: "Concrete type code"
16-31: "Members of the concrete type"
The serializer of the concrete type is found in the factory when the item is written. In the diagram, Polymorphic is the PolymorphicSerializer<Shape>, Factory is the SerializerFactory and Concrete is the serializer registered for the item's own class.
Reading reverses the steps. The type code is read first, the factory supplies the serializer registered under that code, and that serializer constructs and fills the object.
Diagram source
---
config:
sequence:
actorMargin: 8
width: 96
mirrorActors: false
messageMargin: 28
noteMargin: 8
---
sequenceDiagram
participant P as Polymorphic
participant F as Factory
participant C as Concrete
P->>F: TryGetPureSerializer
F-->>P: serializer, code
Note over P: write type code
P->>C: Serialize(item)
3. Register the types in a factory
SerializerFactory.Default holds the built-in registrations and is read-only, so the sample works in a copy.
private static SerializerFactory CreateFactory() {
var factory = new SerializerFactory(SerializerFactory.Default).SetMinTypeCode(1000);
factory.RegisterAutoBuild<Canvas>();
return factory;
}
One RegisterAutoBuild<Canvas>() call registers everything the type needs. It assembles a composite serializer for Canvas, follows its members to Shape, reads the [KnownSubType] attributes, assembles Circle and Rectangle, and registers a polymorphic serializer for the abstract Shape. Base-class members are written before the members of the derived class.
SetMinTypeCode(1000) makes the generated codes start at 1000, clear of the built-in registrations. The sample prints the codes it was given.
Type codes: Canvas=1000, Shape=1003, Circle=1001, Rectangle=1002
Codes are generated in registration order. A type can also be given an explicit code through the optional typeCode argument of the Register overloads, which keeps the code fixed however other registrations change.
4. Serialize and read back
GetSerializer<T>() returns the registered serializer wrapped for null and reference handling.
var writerFactory = CreateFactory();
var serializer = writerFactory.GetSerializer<Canvas>();
var bytes = serializer.SerializeBytesLE(canvas);
The reader builds its own factory with the same registrations and decodes the bytes.
var readerFactory = CreateFactory();
var copy = readerFactory.GetSerializer<Canvas>().DeserializeBytesLE(bytes);
Require(copy.Shapes.Count == 3 && copy.Shapes[0] is Circle { Radius: 2 } && copy.Shapes[1] is Rectangle { Width: 3, Height: 4 } && copy.Shapes[2] is null, "Concrete types were not restored.");
Require(ReferenceEquals(copy.Selected, copy.Shapes[1]), "The shared reference was not restored.");
Each element comes back as its concrete class, and the null element is preserved. Selected referred to the same object as the second list element when the canvas was written, and it still does after reading. The reference prefix recorded the second occurrence as a back-reference, so the object was written once.
5. What the reader must share with the writer
The stream contains type codes and no type names. Both sides therefore need factories in which each code means the same type and the same member layout.
-
Build both factories with the same registration code, in the same order, or assign explicit type codes.
-
When the reader is a different program, or must read data written by an older version, store the factory's definitions with the data.
SerializerFactorySerializerserializes a factory's type codes and registrations so that it can be rebuilt.
A derived type that the factory does not know cannot be written.
canvas.Shapes.Add(new Triangle { Name = "t1", Base = 5 });
try {
serializer.SerializeBytesLE(canvas);
throw new InvalidOperationException("An unregistered sub-type should be rejected.");
} catch (InvalidOperationException error) when (error.Message.Contains("Triangle")) {
Console.WriteLine("Unregistered sub-type rejected: passed");
}
Triangle derives from Shape but has no [KnownSubType] declaration, so serialization throws an InvalidOperationException that names the type. Declare the type on the base class, or register it in the factory before use.
BinarySerializer is the alternative for arbitrary object graphs whose types are not known in advance. It creates the registrations it needs during the call and writes them into the stream ahead of the object.
6. Get the package and run the sample
The serializers ship in the Sphere10.Framework NuGet package. The sample targets .NET 10.
dotnet new console --framework net10.0 --name ArticleSample
cd ArticleSample
dotnet add package Sphere10.Framework --version 3.1.3
The complete MIT-licensed sample contains every file shown above. Extract the download and run it.
dotnet run -c Release
The program writes a canvas with one factory, reads it with another and checks the restored types and the shared reference.
Type codes: Canvas=1000, Shape=1003, Circle=1001, Rectangle=1002
Restored: Circle, Rectangle, null; 47 bytes
Selected is the same instance as Shapes[1]: passed
Unregistered sub-type rejected: passed
The Serialization guide documents the factory, the annotations and the polymorphic serializer, and the PolymorphicSerializer source shows how the concrete serializer is resolved.
The Sphere10 Framework overview describes the rest of the toolkit.
Author: Herman Schoenfeld