1

I have the following interface and it's implementation (with JSON serializers for both Newtonsoft.Json and System.Text.Json):

public interface IAmount {
    decimal Value { get; }
}

[Newtonsoft.Json.JsonConverter(typeof(NewtonsoftJsonConverter))]
[System.Text.Json.Serialization.JsonConverter(typeof(SystemTextJsonConverter))]
public class Amount : IAmount {
    public Amount(decimal value) {
        Value = value;
    }

    public decimal Value { get; }
}

public class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter {
    public override bool CanConvert(Type objectType) => objectType.IsAssignableTo(typeof(IAmount));

    public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) {
        throw new NotImplementedException();
    }

    public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) {
        writer.WriteRawValue(((IAmount?)value)?.Value.ToString());
    }
}

public class SystemTextJsonConverter : System.Text.Json.Serialization.JsonConverter<object> {
    public override bool CanConvert(Type typeToConvert) => typeToConvert.IsAssignableTo(typeof(IAmount));

    public override object Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) {
        throw new NotImplementedException();
    }

    public override void Write(System.Text.Json.Utf8JsonWriter writer, object value, System.Text.Json.JsonSerializerOptions options) {
        writer.WriteRawValue(((IAmount)value).Value.ToString());
    }
}

This works fine if my object is of type Amount. For example (output in the comment next to each line):

var foo = new Amount(10);

Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(foo)); // 10
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(foo)); // 10

However if the object is of type IAmount it works fine for Newtonsoft.Json but not for System.Text.Json. For example:

IAmount foo = new Amount(10);

Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(foo)); // 10
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(foo)); // {"Value":10}

As you can see the output is different when using System.Text.Json. I tried putting a breakpoint against the CanCovert method, however it was never called.

I can fix this by adding a [System.Text.Json.Serialization.JsonConverter(typeof(SystemTextJsonConverter))] attribute against the interface but ideally I don't wish to do this. Does anyone know of an alternative solution for solving this without having to modify the interface?

Note that switching to Newtonsoft isn't an option.

dbc
  • 104,963
  • 20
  • 228
  • 340
nfplee
  • 7,643
  • 12
  • 63
  • 124
  • The best solution is to use Newtonsoft.Json. Always. – Serge Jun 27 '22 at 15:38
  • I believe this is as designed. System.Text.Json intentionally does not support polymorphism during serialization except when the object to be serialized is explicitly declared to be `object`. When declared as anything else, System.Text.Json serializes using the properties and metadata of the **declared type**, not the concrete type. Since `SystemTextJsonConverter` is applied to the concrete type not the declared type `IAmount`, it is never picked up. See [Why does System.Text Json Serialiser not serialise this generic property but Json.NET does?](https://stackoverflow.com/a/62033671/3744182). – dbc Jun 27 '22 at 15:57
  • Do you need a workaround? If so, how would you expect values declared to be of type `IAmount` to be deserialized? – dbc Jun 27 '22 at 16:01
  • @Serge unfortunately this isn't an option. – nfplee Jun 27 '22 at 16:03

1 Answers1

1

I believe this is as designed. System.Text.Json intentionally does not support polymorphism during serialization except when the object to be serialized is explicitly declared to be object. From the docs:

Serialize properties of derived classes

Serialization of a polymorphic type hierarchy is not supported. For example, if a property is defined as an interface or an abstract class, only the properties defined on the interface or abstract class are serialized, even if the runtime type has additional properties. The exceptions to this behavior are explained in this section....

To serialize the properties of [a] derived type, use one of the following approaches:

  1. Call an overload of Serialize that lets you specify the type at runtime...

  2. Declare the object to be serialized as object.

While the documentation only states that properties of derived classes are not serialized, I believe that, since System.Text.Json is internally a contract-based serializer, it uses the entire contract of the declared type when serializing a derived type. Thus the metadata (including JsonConverterAttribute and any other JSON attributes that have been applied) as well as the properties are taken by reflecting the declared type (here IAmount) not the actual type (here Amount).

So, what are your options to work around this restriction?

Firstly, if IAmount is only ever implemented as Amount, you could introduce a JsonConverter which always serializes one type as some other compatible type:

public class AbstractToConcreteConverter<TAbstract, TConcrete> : JsonConverter<TAbstract> where TConcrete : TAbstract
{
    static AbstractToConcreteConverter()
    {
        if (typeof(TAbstract) == typeof(TConcrete))
            throw new ArgumentException(string.Format("Identical type {0} used for both TAbstract and TConcrete", typeof(TConcrete)));
    }
    
    public override TAbstract? Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) =>
        JsonSerializer.Deserialize<TConcrete>(ref reader, options);

    public override void Write(System.Text.Json.Utf8JsonWriter writer, TAbstract value, System.Text.Json.JsonSerializerOptions options) =>
        JsonSerializer.Serialize(writer, (TConcrete)value!, options);
}

And apply it to IAmount:

[JsonConverter(typeof(AbstractToConcreteConverter<IAmount, Amount>))]
public interface IAmount {
    decimal Value { get; }
}

Demo fiddle #1 here.

Secondly, if you don't care about deserialization at all and want all values declared as interfaces to be serialized as their concrete types, you could introduce a converter factory that does just that:

public class ConcreteInterfaceSerializer : JsonConverterFactory
{
    public override bool CanConvert(Type typeToConvert) => typeToConvert.IsInterface;
    
    class ConcreteInterfaceSerializerOfType<TInterface> : JsonConverter<TInterface> 
    {
        static ConcreteInterfaceSerializerOfType()
        {
            if (!typeof(TInterface).IsAbstract && !typeof(TInterface).IsInterface)
                throw new NotImplementedException(string.Format("Concrete class {0} is not supported", typeof(TInterface)));
        }   
    
        public override TInterface? Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) =>
            throw new NotImplementedException();

        public override void Write(System.Text.Json.Utf8JsonWriter writer, TInterface value, System.Text.Json.JsonSerializerOptions options) =>
            JsonSerializer.Serialize<object>(writer, value!, options);
    }
    
    public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options) => 
        (JsonConverter)Activator.CreateInstance(
            typeof(ConcreteInterfaceSerializerOfType<>).MakeGenericType(new Type[] { type }),
            BindingFlags.Instance | BindingFlags.Public,
            binder: null,
            args: Array.Empty<object>(),
            culture: null).ThrowOnNull();
}

public static class ObjectExtensions
{
    public static T ThrowOnNull<T>(this T? value) where T : class => value ?? throw new ArgumentNullException();
}

And either apply it directly to IAmount:

[JsonConverter(typeof(ConcreteInterfaceSerializer))]
public interface IAmount {
    decimal Value { get; }
}

Or add it in options:

var options = new JsonSerializerOptions
{
    Converters = { new ConcreteInterfaceSerializer() },
};
var systemJson = System.Text.Json.JsonSerializer.Serialize<IAmount>(foo, options);

Demo fiddle #2 here.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • Thanks, the second option will work perfect for my scenario. Also thanks for the `ThrowOnNull` extension method. This will allow me to get rid of a bunch of exclamation marks. – nfplee Jun 28 '22 at 07:33