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
.
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.