Home > Software engineering >  Exception serializing Anonymous Type to JSON when nested property names are the same in .net5
Exception serializing Anonymous Type to JSON when nested property names are the same in .net5

Time:02-02

I'm migrating some test projects from .netcore 3.1 to .net6 and hit an unexpected exception while serializing an anonymous type to json.

var obj = new 
{
  input = new {
    foo = "foo"
  },
  INPUT = new {
    foo = "foo"
  }
};

var json = JsonSerializer.Serialize(obj); // throws an exception

Previously in .netcore 3.1 this was acceptable and would return:

{"input":{"foo":"foo"},"INPUT":{"foo":"bar"}}

But in .net6 an exception is thrown:

Unhandled exception. System.InvalidOperationException: 
Members 'input' and 'INPUT' on type 
'<>f__AnonymousType0`2[<>f__AnonymousType1`1[System.String],<>f__AnonymousType1`1[System.String]]' 
cannot both bind with parameter 'input' in the deserialization constructor.

I've tried various JsonSerializerOptions thinking that case sensitivity or insensitivity was the issue with no luck.

However if I created my Anonymous Type as classes, JsonSerialization has no problems:

public class Obj
{
    public Input input { get; set; }
    public Input INPUT { get; set; }
}
    
public class Input
{
    public string foo { get; set; } 
}   
var obj = new Obj(){ input = new Input() { foo = "bar" }, INPUT = new Input() { foo = "bar" }};
var json = JsonSerializer.Serialize(obj);

returns:

{"input":{"foo":"bar"},"INPUT":{"foo":"bar"}}

So what is it about serializing anonymous types that is causing this exception?

CodePudding user response:

The breaking change (arguably a regression) was introduced in .NET 5:

Support deserializing objects using parameterized constructors

It looks to be symptomatic only for types with parameterized constructors with multiple arguments whose names differ only in case, specifically input and INPUT in your example. As documented in How to use immutable types and non-public accessors with System.Text.Json,

The parameter names of a parameterized constructor must match the property names and types. Matching is case-insensitive, and the constructor parameter must match the actual property name even if you use [JsonPropertyName] to rename a property.

It appears that it is the case-insensitive constructor parameter matching that is tripping the serializer up. An anonymous type has exactly one constructor -- an auto-generated parameterized constructor whose argument names match the property names exactly. And because the case-insensitive parameter-to-constructor-argument binding algorithm does not distinguish between input and INPUT in the argument list, an exception gets thrown during contract generation that multiple properties cannot both bind with parameter 'input'.

Now, arguably, the exception should not be thrown during contract generation, but during deserialization. After all, if System.Text.Json cannot determine a unique constructor to use during deserialization, it still will allow you to serialize the type (demo here). You may want to report an issue to Microsoft regarding the regression.

As a workaround, you will need to serialize using types for which this is not a problem, e.g.:

  1. Use mutable types with parameterless constructors (which you are doing with your Obj class).

  2. Use records with distinct property names whose JSON property names are overridden via JsonPropertyNameAttribute, e.g.:

    public record Foo(string foo);
    
    public record Input([property:JsonPropertyName("input")] Foo lowerInput, [property:JsonPropertyName("INPUT")] Foo upperInput);
    
    var obj = new Input(new("foo"), new("foo"));
    
    var json = JsonSerializer.Serialize(obj); // Works fine.
    

    Demo #1 here.

    Or if you prefer a more generic solution, you can use a record for the object with the case-invariant duplicate names and anonymous types for everything else like so:

    public static class InputExtensions
    {
        public record Input<TInput>([property:JsonPropertyName("input")] TInput lowerInput, [property:JsonPropertyName("INPUT")] TInput upperInput);
        public static Input<TInput> ToInput<TInput>(this (TInput, TInput) pair) => new Input<TInput>(pair.Item1, pair.Item2);
    }
    
    var obj = (new { foo = "foo" }, new { foo = "foo" }).ToInput();
    
    var json = JsonSerializer.Serialize(obj); // works fine.
    

    Demo #2 here.

  3. Use a dictionary:

    var obj = new [] { ("input", new { foo = "foo" } ), ("INPUT", new { foo = "foo" } ) }
        .ToDictionary(i => i.Item1, i => i.Item2);
    
    var json = JsonSerializer.Serialize(obj); // works fine.
    

    Demo #3 here.

  4. Revert back to Json.NET temporarily until the regression is fixed by Microsoft, as suggested by Serge.

  • Related