Search code examples
c#.netjson.netdeserializationsystem.text.json

System.Text.Json: How to deserialize class with interface properties (.NET 6)


I'm currently in the process of migrating vom Newtonsoft to System.Text.Json. Newtonsoft was able to automatically deserialize objects, that have one or more Interface properties. With System.Text.Json I get the following error message for the respective classes when I try to accomplish the same:

each parameter in the deserialization constructor must bind to an object property or field on deserialization. Each parameter name must match with a property or field on the object.

I am able to avoid this problem by writing custom converters, but this would result in a lot of overhead for objects with multiple nested layers, where each Interface property can again have multiple Interface properties on their own (thus requiring multiple custom converters). Is there a simpler solution for that problem?

I create an example to illustrate the problem:

 [Fact]
        public void JsonTest()
        {

            var child = new Child(new Name("Peter"), 10);
            var parent = new Parent(child);

            var str = JsonSerializer.Serialize(parent);
            var jsonObj = JsonSerializer.Deserialize<Parent>(str);
            Console.WriteLine(jsonObj!.Child.Name);
        }
    }

    [JsonConverter(typeof(ParentConverter))]
    public class Parent
    {
        public Parent(Child child)
        {
            Child = child;
        }

        public IChild Child { get; set;}
    }

    [JsonConverter(typeof(ChildConverter))]
    public class Child : IChild
    {
        [JsonConstructor]
        public Child(Name name, int age)
        {
            Name = name;
            Age = age;
        }

        public IName Name { get; set; }
        public int Age { get; set; }
    }

    public class Name : IName
    {
        public string NameValue { get; set; }
        public Name(string nameValue)
        {
            NameValue = nameValue;
        }
    }

    public interface IChild
    {
        IName Name { get; }
        int Age { get; }
    }

    public interface IName
    {
        string NameValue { get; }
    }

    public class ParentConverter : JsonConverter<Parent>
    {
        public override Parent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            using (JsonDocument document = JsonDocument.ParseValue(ref reader))
            {
                JsonElement root = document.RootElement;

                if (root.TryGetProperty("Child", out JsonElement childElement))
                {
                    Child? child = JsonSerializer.Deserialize<Child>(childElement.GetRawText(), options);

                    return new Parent(child!);
                }
            }

            throw new JsonException("Invalid JSON data");
        }

        public override void Write(Utf8JsonWriter writer, Parent value, JsonSerializerOptions options)
        {
            writer.WriteStartObject();

            writer.WritePropertyName("Child");
            JsonSerializer.Serialize(writer, value.Child, options);

            writer.WriteEndObject();
        }
    }

    public class ChildConverter : JsonConverter<Child>
    {
        public override Child Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            using (JsonDocument document = JsonDocument.ParseValue(ref reader))
            {
                var name = new Name("Alex");
                var age = 20;

                JsonElement root = document.RootElement;

                if (root.TryGetProperty("Name", out JsonElement nameElement))
                {
                    name = JsonSerializer.Deserialize<Name>(nameElement.GetRawText(), options);
                }

                if (root.TryGetProperty("Age", out JsonElement ageElement))
                {
                    age = JsonSerializer.Deserialize<int>(ageElement.GetRawText(), options);
                }
                return new Child(name!, age);
            }

            throw new JsonException("Invalid JSON data");
        }

        public override void Write(Utf8JsonWriter writer, Child value, JsonSerializerOptions options)
        {
            writer.WriteStartObject();

            writer.WritePropertyName("Name");
            JsonSerializer.Serialize(writer, value.Name, options);

            writer.WritePropertyName("Age");
            JsonSerializer.Serialize(writer, value.Age, options);

            writer.WriteEndObject();
        }
    }

With these custom converters I get the intended behaviour. But is there a way to avoid writing a custom converter for each such classes?


Solution

  • This is called polymorphic serialization. The library needs some way to annotate what specific implementation of IChild a property actually is. I think Newtonsoft adds a property with the complete type name, while this is easy to use, it has some potential downsides if you want to rename your class.

    System.Text.Json uses attributes to annotate the derived types, and require you to supply the identifiers. Read more about Polymorphic type discriminators.

        [JsonDerivedType(typeof(Child1), typeDiscriminator: "Child1")]
        [JsonDerivedType(typeof(Child2), typeDiscriminator: "Child2")]
        public interface IChild
        {
            int Age { get; }
        }
    
        public class Child1 : IChild
        {
            public string PreferedToy { get; set; }
            public int Age { get; set; }
        }
        public class Child2 : IChild
        {
            public string Name { get; set; }
            public int Age { get; set; }
        }
    

    If you only have one implementation of each interface I would consider either getting rid of the interfaces, or converting your objects to Data Transfer Objects (DTOs) that are as simple to serialize as possible. Using DTOs helps separate the concerns of serializing from from any logic in your classes.