System.Text.Json serialize derived class property

I'm migrating a project from Newtonsoft.Json to System.Text.Json in .NET 5.

I have class:

abstract class Car
{
    public string Name { get; set; } = "Default Car Name";
}

class Tesla : Car
{
    public string TeslaEngineName { get; set; } = "My Tesla Engine";
}

I tried:

var cars = new List<Car> 
{ 
    new Tesla(),
    new Tesla(),
    new Tesla()
};

var json = JsonSerializer.Serialize(cars);

Console.WriteLine(json);

The output is:

[
  {"Name":"Default Car Name"},
  {"Name":"Default Car Name"},
  {"Name":"Default Car Name"}
]

Which lost my property: TeslaEngineName.

So how can I serialize derived an object with all properties?


Solution 1:

This is a design feature of System.Text.Json, see here for more details. Your options are:

  1. Keep using JSON.Net

  2. Use one of the workarounds, in this case use a List<object> or cast your list when serialising. For example:

    var json = JsonSerializer.Serialize(cars.Cast<object>());
                                             ^^^^^^^^^^^^^^
    

    The downside of this option is that it will still not serialise any nested properties of derived classes.

Solution 2:

I ran into this problem as well when I was generating some JSON for a personal project - I had a recursive polymorphic data model that I wanted turned into JSON, but the presence of sub-objects that were derived types of the root object in the root object made the serializer just spit out the base type's properties for all of the derived types. I fiddled around with a JsonConverter and reflection for a few hours and came up with a sledgehammer solution that did what I needed it to do.

Basically what this is doing is manually walking each object in the graph and serializing each member that is not a reference type using the default serializer, but upon encountering an instance of a reference type (that is not a string) I dynamically generate a new JsonConverter to handle that type and add it to the "Converters" list, then recursively use that new instance to serialize the sub-object as its true runtime type.

You might be able to use it as a starting point towards a solution that can do what you need.

The converter:

/// <summary>
/// Instructs the JsonSerializer to serialize an object as its runtime type and not the type parameter passed into the Write function.
/// </summary>
public class RuntimeTypeJsonConverter<T> : JsonConverter<T>
{    
    private static readonly Dictionary<Type, PropertyInfo[]> _knownProps = new Dictionary<Type, PropertyInfo[]>(); //cache mapping a Type to its array of public properties to serialize
    private static readonly Dictionary<Type, JsonConverter> _knownConverters = new Dictionary<Type, JsonConverter>(); //cache mapping a Type to its respective RuntimeTypeJsonConverter instance that was created to serialize that type. 
    private static readonly Dictionary<Type, Type> _knownGenerics = new Dictionary<Type, Type>(); //cache mapping a Type to the type of RuntimeTypeJsonConverter generic type definition that was created to serialize that type

    public override bool CanConvert(Type typeToConvert)
    {
        return typeToConvert.IsClass && typeToConvert != typeof(string); //this converter is only meant to work on reference types that are not strings
    }

    public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var deserialized = JsonSerializer.Deserialize(ref reader, typeToConvert, options); //default read implementation, the focus of this converter is the Write operation
        return (T)deserialized;
    }

    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {            
        if (value is IEnumerable) //if the value is an IEnumerable of any sorts, serialize it as a JSON array. Note that none of the properties of the IEnumerable are written, it is simply iterated over and serializes each object in the IEnumerable
        {             
            WriteIEnumerable(writer, value, options);
        }
        else if (value != null && value.GetType().IsClass == true) //if the value is a reference type and not null, serialize it as a JSON object.
        {
            WriteObject(writer, value, ref options);    
        }       
        else //otherwise just call the default serializer implementation of this Converter is asked to serialize anything not handled in the other two cases
        {
            JsonSerializer.Serialize(writer, value);
        }
    }

    /// <summary>
    /// Writes the values for an object into the Utf8JsonWriter
    /// </summary>
    /// <param name="writer">The writer to write to.</param>
    /// <param name="value">The value to convert to Json.</param>
    /// <param name="options">An object that specifies the serialization options to use.</param>
    private void WriteObject(Utf8JsonWriter writer, T value, ref JsonSerializerOptions options)
    {
        var type = value.GetType();

        //get all the public properties that we will be writing out into the object
        PropertyInfo[] props = GetPropertyInfos(type);

        writer.WriteStartObject();

        foreach (var prop in props)
        {
            var propVal = prop.GetValue(value);
            if (propVal == null) continue; //don't include null values in the final graph

            writer.WritePropertyName(prop.Name);
            var propType = propVal.GetType(); //get the runtime type of the value regardless of what the property info says the PropertyType should be

            if (propType.IsClass && propType != typeof(string)) //if the property type is a valid type for this JsonConverter to handle, do some reflection work to get a RuntimeTypeJsonConverter appropriate for the sub-object
            {
                Type generic = GetGenericConverterType(propType); //get a RuntimeTypeJsonConverter<T> Type appropriate for the sub-object
                JsonConverter converter = GetJsonConverter(generic); //get a RuntimeTypeJsonConverter<T> instance appropriate for the sub-object

                //look in the options list to see if we don't already have one of these converters in the list of converters in use (we may already have a converter of the same type, but it may not be the same instance as our converter variable above)
                var found = false;
                foreach (var converterInUse in options.Converters)
                {
                    if (converterInUse.GetType() == generic)
                    {
                        found = true;
                        break;
                    }
                }

                if (found == false) //not in use, make a new options object clone and add the new converter to its Converters list (which is immutable once passed into the Serialize method).
                {
                    options = new JsonSerializerOptions(options);
                    options.Converters.Add(converter);
                }

                JsonSerializer.Serialize(writer, propVal, propType, options);
            }
            else //not one of our sub-objects, serialize it like normal
            {
                JsonSerializer.Serialize(writer, propVal);
            }
        }

        writer.WriteEndObject();
    }

    /// <summary>
    /// Gets or makes RuntimeTypeJsonConverter generic type to wrap the given type parameter.
    /// </summary>
    /// <param name="propType">The type to get a RuntimeTypeJsonConverter generic type for.</param>
    /// <returns></returns>
    private Type GetGenericConverterType(Type propType)
    {
        Type generic = null;
        if (_knownGenerics.ContainsKey(propType) == false)
        {
            generic = typeof(RuntimeTypeJsonConverter<>).MakeGenericType(propType);
            _knownGenerics.Add(propType, generic);
        }
        else
        {
            generic = _knownGenerics[propType];
        }

        return generic;
    }

    /// <summary>
    /// Gets or creates the corresponding RuntimeTypeJsonConverter that matches the given generic type defintion.
    /// </summary>
    /// <param name="genericType">The generic type definition of a RuntimeTypeJsonConverter.</param>
    /// <returns></returns>
    private JsonConverter GetJsonConverter(Type genericType)
    {
        JsonConverter converter = null;
        if (_knownConverters.ContainsKey(genericType) == false)
        {
            converter = (JsonConverter)Activator.CreateInstance(genericType);
            _knownConverters.Add(genericType, converter);
        }
        else
        {
            converter = _knownConverters[genericType];
        }

        return converter;
    }



    /// <summary>
    /// Gets all the public properties of a Type.
    /// </summary>
    /// <param name="t"></param>
    /// <returns></returns>
    private PropertyInfo[] GetPropertyInfos(Type t)
    {
        PropertyInfo[] props = null;

        if (_knownProps.ContainsKey(t) == false)
        {
            props = t.GetProperties();
            _knownProps.Add(t, props);
        }
        else
        {
            props = _knownProps[t];
        }

        return props;
    }

    /// <summary>
    /// Writes the values for an object that implements IEnumerable into the Utf8JsonWriter
    /// </summary>
    /// <param name="writer">The writer to write to.</param>
    /// <param name="value">The value to convert to Json.</param>
    /// <param name="options">An object that specifies the serialization options to use.</param>
    private void WriteIEnumerable(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        writer.WriteStartArray();

        foreach (object item in value as IEnumerable)
        {
            if (item == null) //preserving null gaps in the IEnumerable
            {
                writer.WriteNullValue();
                continue;
            }

            JsonSerializer.Serialize(writer, item, item.GetType(), options);
        }

        writer.WriteEndArray();
    }
}

To use:

        var cars = new List<Car>
        {
            new Tesla(),
            new Tesla(),
            new Tesla()
        };

        var options = new JsonSerializerOptions();
        options.Converters.Add(new RuntimeTypeJsonConverter<object>());

        var json = JsonSerializer.Serialize(cars, cars.GetType(), options);