Search code examples
c#json.netjson-deserializationsystem.text.json

System.Text.Json polymorphic deserialization exception when $type is not the first property


I have noticed some behavior that got me surprised. I have an abstract BaseClass and a DerivedClass.

[JsonPolymorphic]
[JsonDerivedType(typeof(DerivedClass), "derived")]
public abstract class BaseClass
{
    public BaseClass() { }
}
public class DerivedClass : BaseClass
{
    public string? Whatever { get; set; }
}

And now I have two JSON strings: the first JSON has the type discriminator ($type) as the very first property within the JSON - the second JSON string does not. When I perform JsonSerializer.Deserialize<BaseClass>(), an exception is thrown on the second JSON string.

var jsonWorks = "{\"$type\": \"derived\", \"whatever\": \"Bar\"}";
var jsonBreaks = "{\"whatever\": \"Bar\", \"$type\": \"derived\"}";

var obj1 = JsonSerializer.Deserialize<BaseClass>(jsonWorks);
var obj2 = JsonSerializer.Deserialize<BaseClass>(jsonBreaks); // This one will throw an exception

The exception that is thrown is of type System.NotSupportedException with the following message:

System.NotSupportedException: 'Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'MyApp.BaseClass'. Path: $ | LineNumber: 0 | BytePositionInLine: 12.'

It also has an inner exception:

NotSupportedException: Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'MyApp.BaseClass'.

Is this behavior expected or is it actually a potential bug in System.Text.Json? This has been attempted in net8.0.


Solution

  • As of .NET 8 this is a documented limitation of System.Text.Json. From Polymorphic type discriminators:

    Note

    The type discriminator must be placed at the start of the JSON object, grouped together with other metadata properties like $id and $ref.

    If for some reason you cannot serialize your metadata properties before your regular properties, you will need to manually fix the JSON before deserializing, e.g. by preloading into a JsonNode hierarchy and recursively fixing the property order.

    To do that, first define the following extension methods:

    public static partial class JsonExtensions
    {
        const string Id = "$id";
        const string Ref = "$ref";
        
        static bool DefaultIsTypeDiscriminator(string s) => s == "$type";
        
        public static TJsonNode? MoveMetadataToBeginning<TJsonNode>(this TJsonNode? node) where TJsonNode : JsonNode => node.MoveMetadataToBeginning(DefaultIsTypeDiscriminator);
    
        public static TJsonNode? MoveMetadataToBeginning<TJsonNode>(this TJsonNode? node, Predicate<string> isTypeDiscriminator) where TJsonNode : JsonNode
        {
            ArgumentNullException.ThrowIfNull(isTypeDiscriminator);
            foreach (var n in node.DescendantsAndSelf().OfType<JsonObject>())
            {
                var properties = n.ToLookup(p => isTypeDiscriminator(p.Key) || p.Key == Id || p.Key == Ref);
                var newProperties = properties[true].Concat(properties[false]).ToList();
                n.Clear();
                newProperties.ForEach(p => n.Add(p));
            }
            return node;
        } 
        
        // From this answer https://stackoverflow.com/a/73887518/3744182
        // To https://stackoverflow.com/questions/73887517/how-to-recursively-descend-a-system-text-json-jsonnode-hierarchy-equivalent-to
        public static IEnumerable<JsonNode?> Descendants(this JsonNode? root) => root.DescendantsAndSelf(false);
    
        /// Recursively enumerates all JsonNodes in the given JsonNode object in document order.
        public static IEnumerable<JsonNode?> DescendantsAndSelf(this JsonNode? root, bool includeSelf = true) => 
            root.DescendantItemsAndSelf(includeSelf).Select(i => i.node);
        
        /// Recursively enumerates all JsonNodes (including their index or name and parent) in the given JsonNode object in document order.
        public static IEnumerable<(JsonNode? node, int? index, string? name, JsonNode? parent)> DescendantItemsAndSelf(this JsonNode? root, bool includeSelf = true) => 
            RecursiveEnumerableExtensions.Traverse(
                (node: root, index: (int?)null, name: (string?)null, parent: (JsonNode?)null),
                (i) => i.node switch
                {
                    JsonObject o => o.AsDictionary().Select(p => (p.Value, (int?)null, p.Key.AsNullableReference(), i.node.AsNullableReference())),
                    JsonArray a => a.Select((item, index) => (item, index.AsNullableValue(), (string?)null, i.node.AsNullableReference())),
                    _ => i.ToEmptyEnumerable(),
                }, includeSelf);
        
        static IEnumerable<T> ToEmptyEnumerable<T>(this T item) => Enumerable.Empty<T>();
        static T? AsNullableReference<T>(this T item) where T : class => item;
        static Nullable<T> AsNullableValue<T>(this T item) where T : struct => item;
        static IDictionary<string, JsonNode?> AsDictionary(this JsonObject o) => o;
    }
    
    public static partial class RecursiveEnumerableExtensions
    {
        // Rewritten from the answer by Eric Lippert https://stackoverflow.com/users/88656/eric-lippert
        // to "Efficient graph traversal with LINQ - eliminating recursion" http://stackoverflow.com/questions/10253161/efficient-graph-traversal-with-linq-eliminating-recursion
        // to ensure items are returned in the order they are encountered.
        public static IEnumerable<T> Traverse<T>(
            T root,
            Func<T, IEnumerable<T>> children, bool includeSelf = true)
        {
            if (includeSelf)
                yield return root;
            var stack = new Stack<IEnumerator<T>>();
            try
            {
                stack.Push(children(root).GetEnumerator());
                while (stack.Count != 0)
                {
                    var enumerator = stack.Peek();
                    if (!enumerator.MoveNext())
                    {
                        stack.Pop();
                        enumerator.Dispose();
                    }
                    else
                    {
                        yield return enumerator.Current;
                        stack.Push(children(enumerator.Current).GetEnumerator());
                    }
                }
            }
            finally
            {
                foreach (var enumerator in stack)
                    enumerator.Dispose();
            }
        }
    }
    

    And then you can do:

    var obj1 = JsonNode.Parse(jsonWorks).MoveMetadataToBeginning().Deserialize<BaseClass>();
    var obj2 = JsonNode.Parse(jsonBreaks).MoveMetadataToBeginning().Deserialize<BaseClass>();
    

    Notes:

    • System.Text.Json does not have a hardcoded type discriminator name, so the code above assumes that the type discriminator has the default name "$type".

      If you are using different type discriminators, pass in an appropriate predicate to the overload:

       MoveMetadataToBeginning<TJsonNode>(this TJsonNode? node, Predicate<string> isTypeDiscriminator)
      
    • Json.NET has a similar limitation, however it can be overcome by enabling MetadataPropertyHandling.ReadAhead in settings.

    • According to MSFT this limitation was made for performance reasons. See this comment by Eirik Tsarpalis for details.

    Demo fiddle here.