Edit

Share via


How to serialize properties of derived classes with System.Text.Json

In this article, you learn how to serialize properties of derived classes with the System.Text.Json namespace.

Serialize properties of derived classes

Beginning with .NET 7, System.Text.Json supports polymorphic type hierarchy serialization and deserialization with attribute annotations.

Attribute Description
JsonDerivedTypeAttribute When placed on a type declaration, indicates that the specified subtype should be opted into polymorphic serialization. It also exposes the ability to specify a type discriminator.
JsonPolymorphicAttribute When placed on a type declaration, indicates that the type should be serialized polymorphically. It also exposes various options to configure polymorphic serialization and deserialization for that type.

For example, suppose you have a WeatherForecastBase class and a derived class WeatherForecastWithCity:

C#
[JsonDerivedType(typeof(WeatherForecastWithCity))]
public class WeatherForecastBase
{
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}
C#
public class WeatherForecastWithCity : WeatherForecastBase
{
    public string? City { get; set; }
}

And suppose the type argument of the Serialize<TValue> method at compile time is WeatherForecastBase:

C#
options = new JsonSerializerOptions
{
    WriteIndented = true
};
jsonString = JsonSerializer.Serialize<WeatherForecastBase>(weatherForecastBase, options);

In this scenario, the City property is serialized because the weatherForecastBase object is actually a WeatherForecastWithCity object. This configuration enables polymorphic serialization for WeatherForecastBase, specifically when the runtime type is WeatherForecastWithCity:

JSON
{
  "City": "Milwaukee",
  "Date": "2022-09-26T00:00:00-05:00",
  "TemperatureCelsius": 15,
  "Summary": "Cool"
}

While round-tripping of the payload as WeatherForecastBase is supported, it won't materialize as a run-time type of WeatherForecastWithCity. Instead, it will materialize as a run-time type of WeatherForecastBase:

C#
WeatherForecastBase value = JsonSerializer.Deserialize<WeatherForecastBase>("""
    {
      "City": "Milwaukee",
      "Date": "2022-09-26T00:00:00-05:00",
      "TemperatureCelsius": 15,
      "Summary": "Cool"
    }
    """);

Console.WriteLine(value is WeatherForecastWithCity); // False

The following section describes how to add metadata to enable round-tripping of the derived type.

Polymorphic type discriminators

To enable polymorphic deserialization, you must specify a type discriminator for the derived class:

C#
[JsonDerivedType(typeof(WeatherForecastBase), typeDiscriminator: "base")]
[JsonDerivedType(typeof(WeatherForecastWithCity), typeDiscriminator: "withCity")]
public class WeatherForecastBase
{
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

public class WeatherForecastWithCity : WeatherForecastBase
{
    public string? City { get; set; }
}

With the added metadata, specifically, the type discriminator, the serializer can serialize and deserialize the payload as the WeatherForecastWithCity type from its base type WeatherForecastBase. Serialization emits JSON along with the type discriminator metadata:

C#
WeatherForecastBase weather = new WeatherForecastWithCity
{
    City = "Milwaukee",
    Date = new DateTimeOffset(2022, 9, 26, 0, 0, 0, TimeSpan.FromHours(-5)),
    TemperatureCelsius = 15,
    Summary = "Cool"
}
var json = JsonSerializer.Serialize<WeatherForecastBase>(weather, options);
Console.WriteLine(json);
// Sample output:
//   {
//     "$type" : "withCity",
//     "City": "Milwaukee",
//     "Date": "2022-09-26T00:00:00-05:00",
//     "TemperatureCelsius": 15,
//     "Summary": "Cool"
//   }

With the type discriminator, the serializer can deserialize the payload polymorphically as WeatherForecastWithCity:

C#
WeatherForecastBase value = JsonSerializer.Deserialize<WeatherForecastBase>(json);
Console.WriteLine(value is WeatherForecastWithCity); // True

Note

By default, the $type discriminator must be placed at the start of the JSON object, grouped together with other metadata properties like $id and $ref. If you're reading data off an external API that places the $type discriminator in the middle of the JSON object, set JsonSerializerOptions.AllowOutOfOrderMetadataProperties to true:

C#
JsonSerializerOptions options = new() { AllowOutOfOrderMetadataProperties = true };
JsonSerializer.Deserialize<Base>("""{"Name":"Name","$type":"derived"}""", options);

Be careful when you enable this flag, as it might result in over-buffering (and out-of-memory failures) when performing streaming deserialization of very large JSON objects.

Mix and match type discriminator formats

Type discriminator identifiers are valid in either string or int forms, so the following is valid:

C#
[JsonDerivedType(typeof(WeatherForecastWithCity), 0)]
[JsonDerivedType(typeof(WeatherForecastWithTimeSeries), 1)]
[JsonDerivedType(typeof(WeatherForecastWithLocalNews), 2)]
public class WeatherForecastBase { }

var json = JsonSerializer.Serialize<WeatherForecastBase>(new WeatherForecastWithTimeSeries());
Console.WriteLine(json);
// Sample output:
//   {
//    "$type" : 1,
//    Omitted for brevity...
//   }

While the API supports mixing and matching type discriminator configurations, it's not recommended. The general recommendation is to use either all string type discriminators, all int type discriminators, or no discriminators at all. The following example shows how to mix and match type discriminator configurations:

C#
[JsonDerivedType(typeof(ThreeDimensionalPoint), typeDiscriminator: 3)]
[JsonDerivedType(typeof(FourDimensionalPoint), typeDiscriminator: "4d")]
public class BasePoint
{
    public int X { get; set; }
    public int Y { get; set; }
}

public class ThreeDimensionalPoint : BasePoint
{
    public int Z { get; set; }
}

public sealed class FourDimensionalPoint : ThreeDimensionalPoint
{
    public int W { get; set; }
}

In the preceding example, the BasePoint type doesn't have a type discriminator, while the ThreeDimensionalPoint type has an int type discriminator, and the FourDimensionalPoint has a string type discriminator.

Important

For polymorphic serialization to work, the type of the serialized value should be that of the polymorphic base type. This includes using the base type as the generic type parameter when serializing root-level values, as the declared type of serialized properties, or as the collection element in serialized collections.

C#
using System.Text.Json;
using System.Text.Json.Serialization;

PerformRoundTrip<BasePoint>();
PerformRoundTrip<ThreeDimensionalPoint>();
PerformRoundTrip<FourDimensionalPoint>();

static void PerformRoundTrip<T>() where T : BasePoint, new()
{
    var json = JsonSerializer.Serialize<BasePoint>(new T());
    Console.WriteLine(json);

    BasePoint? result = JsonSerializer.Deserialize<BasePoint>(json);
    Console.WriteLine($"result is {typeof(T)}; // {result is T}");
    Console.WriteLine();
}
// Sample output:
//   { "X": 541, "Y": 503 }
//   result is BasePoint; // True
//
//   { "$type": 3, "Z": 399, "X": 835, "Y": 78 }
//   result is ThreeDimensionalPoint; // True
//
//   { "$type": "4d", "W": 993, "Z": 427, "X": 508, "Y": 741 }
//   result is FourDimensionalPoint; // True

Customize the type discriminator name

The default property name for the type discriminator is $type. To customize the property name, use the JsonPolymorphicAttribute as shown in the following example:

C#
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$discriminator")]
[JsonDerivedType(typeof(ThreeDimensionalPoint), typeDiscriminator: "3d")]
public class BasePoint
{
    public int X { get; set; }
    public int Y { get; set; }
}

public sealed class ThreeDimensionalPoint : BasePoint
{
    public int Z { get; set; }
}

In the preceding code, the JsonPolymorphic attribute configures the TypeDiscriminatorPropertyName to the "$discriminator" value. With the type discriminator name configured, the following example shows the ThreeDimensionalPoint type serialized as JSON:

C#
BasePoint point = new ThreeDimensionalPoint { X = 1, Y = 2, Z = 3 };
var json = JsonSerializer.Serialize<BasePoint>(point);
Console.WriteLine(json);
// Sample output:
//  { "$discriminator": "3d", "X": 1, "Y": 2, "Z": 3 }

Tip

Avoid using a JsonPolymorphicAttribute.TypeDiscriminatorPropertyName that conflicts with a property in your type hierarchy.

Handle unknown derived types

To handle unknown derived types, you must opt in to such support using an annotation on the base type. Consider the following type hierarchy:

C#
[JsonDerivedType(typeof(ThreeDimensionalPoint))]
public class BasePoint
{
    public int X { get; set; }
    public int Y { get; set; }
}

public class ThreeDimensionalPoint : BasePoint
{
    public int Z { get; set; }
}

public class FourDimensionalPoint : ThreeDimensionalPoint
{
    public int W { get; set; }
}

Since the configuration does not explicitly opt-in support for FourDimensionalPoint, attempting to serialize instances of FourDimensionalPoint as BasePoint will result in a run-time exception:

C#
JsonSerializer.Serialize<BasePoint>(new FourDimensionalPoint()); // throws NotSupportedException

You can change the default behavior by using the JsonUnknownDerivedTypeHandling enum, which can be specified as follows:

C#
[JsonPolymorphic(
    UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)]
[JsonDerivedType(typeof(ThreeDimensionalPoint))]
public class BasePoint
{
    public int X { get; set; }
    public int Y { get; set; }
}

public class ThreeDimensionalPoint : BasePoint
{
    public int Z { get; set; }
}

public class FourDimensionalPoint : ThreeDimensionalPoint
{
    public int W { get; set; }
}

Instead of falling back to the base type, you can use the FallBackToNearestAncestor setting to fall back to the contract of the nearest declared derived type:

C#
[JsonPolymorphic(
    UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor)]
[JsonDerivedType(typeof(BasePoint))]
public interface IPoint { }

public class BasePoint : IPoint { }

public class ThreeDimensionalPoint : BasePoint { }

With a configuration like the preceding example, the ThreeDimensionalPoint type will be serialized as BasePoint:

C#
// Serializes using the contract for BasePoint
JsonSerializer.Serialize<IPoint>(new ThreeDimensionalPoint());

However, falling back to the nearest ancestor admits the possibility of "diamond" ambiguity. Consider the following type hierarchy as an example:

C#
[JsonPolymorphic(
    UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor)]
[JsonDerivedType(typeof(BasePoint))]
[JsonDerivedType(typeof(IPointWithTimeSeries))]
public interface IPoint { }

public interface IPointWithTimeSeries : IPoint { }

public class BasePoint : IPoint { }

public class BasePointWithTimeSeries : BasePoint, IPointWithTimeSeries { }

In this case, the BasePointWithTimeSeries type could be serialized as either BasePoint or IPointWithTimeSeries since they are both direct ancestors. This ambiguity will cause the NotSupportedException to be thrown when attempting to serialize an instance of BasePointWithTimeSeries as IPoint.

C#
// throws NotSupportedException
JsonSerializer.Serialize<IPoint>(new BasePointWithTimeSeries());

Configure polymorphism with the contract model

For use cases where attribute annotations are impractical or impossible (such as large domain models, cross-assembly hierarchies, or hierarchies in third-party dependencies), to configure polymorphism use the contract model. The contract model is a set of APIs that can be used to configure polymorphism in a type hierarchy by creating a custom DefaultJsonTypeInfoResolver subclass that dynamically provides polymorphic configuration per type, as shown in the following example:

C#
public class PolymorphicTypeResolver : DefaultJsonTypeInfoResolver
{
    public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
    {
        JsonTypeInfo jsonTypeInfo = base.GetTypeInfo(type, options);

        Type basePointType = typeof(BasePoint);
        if (jsonTypeInfo.Type == basePointType)
        {
            jsonTypeInfo.PolymorphismOptions = new JsonPolymorphismOptions
            {
                TypeDiscriminatorPropertyName = "$point-type",
                IgnoreUnrecognizedTypeDiscriminators = true,
                UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization,
                DerivedTypes =
                {
                    new JsonDerivedType(typeof(ThreeDimensionalPoint), "3d"),
                    new JsonDerivedType(typeof(FourDimensionalPoint), "4d")
                }
            };
        }

        return jsonTypeInfo;
    }
}

Additional polymorphic serialization details

  • Polymorphic serialization supports derived types that have been explicitly opted in via the JsonDerivedTypeAttribute. Undeclared types will result in a run-time exception. The behavior can be changed by configuring the JsonPolymorphicAttribute.UnknownDerivedTypeHandling property.
  • Polymorphic configuration specified in derived types is not inherited by polymorphic configuration in base types. The base type must be configured independently.
  • Polymorphic hierarchies are supported for both interface and class types.
  • Polymorphism using type discriminators is only supported for type hierarchies that use the default converters for objects, collections, and dictionary types.
  • Polymorphism is supported in metadata-based source generation, but not fast-path source generation.

See also