Table of Contents

Polymorphic serialization

Serialization of polymorphic types requires special consideration.

For instance, suppose you want to serialize a Farm:

[GenerateShape]
public partial class Farm
{
    public List<Animal> Animals { get; } =
    [
        new Cow { Name = "Bessie", Weight = 1400 },
        new Horse { Name = "Lightning", Speed = 45 },
        new Dog { Name = "Rover", Color = "Brown" },
    ];
}

[GenerateShape]
public partial class Animal
{
    public string? Name { get; set; }
}

[GenerateShape]
public partial class Cow : Animal
{
    public int Weight { get; set; }
}

[GenerateShape]
public partial class Horse : Animal
{
    public int Speed { get; set; }
}

[GenerateShape]
public partial class Dog : Animal
{
    public string? Color { get; set; }
}

Notice that your animals on the farm are kept in a collection typed with the base type Animal. At runtime, we expect most or all animals to be of a derived type rather than the Animal base type.

By default, serializing the Farm will only serialize animals with the properties that are directly on the Animal class.

{
  "animals": [
    { "name": "Bessie" },
    { "name": "Lightning" },
    { "name": "Rover" }
  ]
}

Note the lack of any type information or properties defined on the derived types. Deserializing this Farm will produce a bunch of Animal objects. If Animal were an abstract class, this would not be deserializable at all.

You can preserve polymorphic type metadata across serialization using DerivedTypeShapeAttribute, which you apply to the declared base type:

[GenerateShape]
[DerivedTypeShape(typeof(UnionCow))]
[DerivedTypeShape(typeof(UnionHorse))]
[DerivedTypeShape(typeof(UnionDog))]
public partial class UnionAnimal
{
    public string? Name { get; set; }
}

[GenerateShape]
public partial class UnionFarm
{
    public List<UnionAnimal> Animals { get; } =
    [
        new UnionCow { Name = "Bessie", Weight = 1400 },
        new UnionHorse { Name = "Lightning", Speed = 45 },
        new UnionDog { Name = "Rover", Color = "Brown" },
    ];
}

[GenerateShape]
public partial class UnionCow : UnionAnimal
{
    public int Weight { get; set; }
}

[GenerateShape]
public partial class UnionHorse : UnionAnimal
{
    public int Speed { get; set; }
}

[GenerateShape]
public partial class UnionDog : UnionAnimal
{
    public string? Color { get; set; }
}

This changes the serialized form to include a discriminator and the payload for each union case.

Now the farm serializes like this:

{
  "animals": [
    { "$type": "Cow", "name": "Bessie", "weight": 1400 },
    { "$type": "Horse", "name": "Lightning", "speed": 45 },
    { "$type": "Dog", "name": "Rover", "color": "Brown" }
  ]
}

Deserializing this will recreate each object with its original derived type and full set of properties.

Unions

A type that stands in for itself and a closed set of derived types is called a union. Nerdbank.Json serializes tagged union cases as JSON objects with a $type discriminator:

{"$type":"TypeName","property":"value"}

For object-shaped payloads, case properties appear alongside $type. Scalar and custom-converter payloads use a $value property. This wrapper is only used when the declared type is the union type. If a property is declared as Horse, then a Horse value is serialized directly without the union wrapper.

Union case identifiers

Each type in a union is a union case and has a discriminator by which it is recognized during deserialization.

When no explicit tag is supplied, Nerdbank.Json uses the case type name for derived types and null for the base type itself. This is the behavior shown in the earlier example.

You can explicitly choose string discriminators:

[GenerateShape]
[DerivedTypeShape(typeof(StringAliasCat), Name = "cat")]
[DerivedTypeShape(typeof(StringAliasDog), Name = "dog")]
public partial class StringAliasAnimal
{
    public string? Name { get; set; }
}

[GenerateShape]
public partial class StringAliasCat : StringAliasAnimal
{
    public int Lives { get; set; }
}

[GenerateShape]
public partial class StringAliasDog : StringAliasAnimal
{
    public string? Breed { get; set; }
}

Or integer discriminators:

[GenerateShape]
[DerivedTypeShape(typeof(IntAliasCat), Tag = 1)]
[DerivedTypeShape(typeof(IntAliasDog), Tag = 2)]
public partial class IntAliasAnimal
{
    public string? Name { get; set; }
}

[GenerateShape]
public partial class IntAliasCat : IntAliasAnimal
{
    public int Lives { get; set; }
}

[GenerateShape]
public partial class IntAliasDog : IntAliasAnimal
{
    public string? Breed { get; set; }
}

Integer discriminators reduce payload size and avoid coupling the wire format to CLR type names.

Unknown derived types

Union support is closed-world. Only the base type and the derived types explicitly listed with DerivedTypeShapeAttribute are preserved polymorphically.

If a runtime object is more derived than any registered union case, Nerdbank.Json will serialize it as the nearest registered base case. On deserialization, the object is rehydrated as that registered case, not as the original more-derived runtime type.

Runtime configuration

Attribute-driven metadata is the default, but DerivedTypeUnions can add, replace, extend, or disable unions per serializer. See runtime union configuration for explicit shape mappings, JSON object envelopes, and experimental discriminator-free duck typing.

C# union types

C# 15 introduces union types, which hold one of a closed set of payload types without requiring a common base type. Nerdbank.Json supports these unions with GenerateShapeAttribute:

[GenerateShape]
public partial union Result(int, string?);

Serialize and deserialize a union like any other shaped type:

public static Result Roundtrip()
{
    JsonSerializer serializer = new();
    string json = serializer.Serialize(new Result(42)); // 42
    return serializer.Deserialize<Result>(json);
}

Cases with an unambiguous native JSON representation omit type metadata. For example, the preceding Result union serializes new Result(42) as 42 and new Result("hi") as "hi". Object-shaped cases, nested unions, and custom payload converters use a discriminator envelope. Tagged cases use $type objects, such as {"$type":"Dog","name":"Rover"}.

A null payload selects the first nullable untagged case, so the example union's default value serializes as null. A default value that matches no case cannot be serialized. Recursive unions and hand-authored types implementing the C# union member pattern are also supported. C# unions have no base-type fallback.

JSON cannot distinguish numeric CLR widths, signedness, or float from double. Likewise, more than one string-shaped or collection-shaped case is ambiguous. Such cases must have explicit identifiers; the serializer rejects an ambiguous union rather than guessing while deserializing. Only one untagged array case and one untagged dictionary/object case are permitted. Tagged cases always use $type objects. If an untagged dictionary/object case would collide with those envelopes, it is also tagged. For stable protocols, assign explicit aliases rather than relying on inferred case names. Metadata must be first by default, or AllowOutOfOrderMetadataProperties may be enabled to accept it later. Payload converters, reference preservation, asynchronous serialization, and generated JSON schemas work with these unions as well. Asynchronous operations forward to the payload converters, including asynchronous-only converters and payloads containing IAsyncEnumerable<T>. Generated schemas describe each untagged native case and each tagged envelope. The union keyword examples require C# 15 and .NET 11, or union compiler polyfills when targeting earlier frameworks.

Custom C# union case identifiers

Set JsonSerializer.UnionTypeAliasProvider to assign stable string or integer identifiers. This does not change the union's payload types or their converters, and does not affect type hierarchies, F# unions, or runtime derived type mappings.

Use UnionCaseMapping for explicit per-union registrations:

public static JsonSerializer CreateSerializer()
{
    UnionCaseMapping mapping = new(typeof(Result))
    {
        { "number", typeof(int) },
        { "text", typeof(string) },
    };
    return new JsonSerializer { UnionTypeAliasProvider = new UnionCaseMappingCollection([mapping]) };
}

No type shape provider or inheritance relationship is needed for registration. Integer aliases can be used in place of strings. The same payload type may have different aliases in different unions. UnionCaseMappingCollection implements IUnionTypeAliasProvider and freezes each mapping when it is added. Further mutation throws InvalidOperationException. Duplicate union registrations, payload types, and identifiers within a mapping are rejected. Registered payload types are checked against the union when its converter is constructed.

Use DelegateUnionTypeAliasProvider to consult an existing registry or resolve identifiers from the containing union and payload types:

public static JsonSerializer CreateSerializerWithDelegate()
{
    return new JsonSerializer
    {
        UnionTypeAliasProvider = new DelegateUnionTypeAliasProvider((unionType, caseType) =>
            unionType == typeof(Result) && caseType == typeof(int) ? new DerivedTypeIdentifier(1) : (DerivedTypeIdentifier?)null),
    };
}

For access to the full shapes, implement IUnionTypeAliasProvider directly. Providers must be thread-safe and return consistent results for as long as they are used. Aliases are resolved when constructing converters, not for every serialized value. Returning null, or leaving a case unregistered, retains the normal identifier for that case.

Custom aliases apply to serialization, deserialization, and JSON schemas. Assigning an alias explicitly opts that case into the tagged representation, even when its native token would otherwise be unambiguous. An overridden case accepts only its custom identifier, not its original identifier. Uninitialized identifiers and identifiers that collide with another case's custom or default identifier are rejected.

Custom converters

A custom converter applied directly to a union-typed member takes over that member's entire serialization shape, including any discriminator handling. By contrast, a converter applied to a concrete union case type only controls that concrete payload and still participates inside the built-in union envelope.

For general guidance on authoring converters, see custom converters.