Can I make Json.net deserialize a C# 9 record type with the "primary" constructor, as if it had [JsonConstructor]?
Asked Answered
P

4

32

Using C# 9 on .NET 5.0, I have a bunch of record types, like this:

public record SomethingHappenedEvent(Guid Id, object TheThing, string WhatHappened)
{
    public SomethingHappenedEvent(object theThing, string whatHappened)
        : this(Guid.NewGuid(), theThing, whatHappened)
    { }
}

As you might expect, they get serialized and sent elsewhere for handling. Senders call the two-argument constructor and get a new Id, but the deserializer needs to use the "primary" 3-argument constructor implied by the record declaration. I'm using Newtonsoft Json.NET and I sure wish this worked:

        var record = new SomethingHappenedEvent("roof", "caught fire");
        var json = JsonConvert.SerializeObject(record);
        var otherSideRecord = JsonConvert.DeserializeObject<SomethingHappenedEvent>(json);
        Assert.AreEqual(record, otherSideRecord);

Of course it doesn't. It throws JsonSerializationException. It can't find the right constructor because there are two, neither is a default zero-argument constructor, and neither is marked with JsonConstructorAttribute. My question is really "What options do I have for getting something similar?". This would be great:

[JsonConstructor]
public record SomethingHappenedEvent(Guid Id, object TheThing, string WhatHappened)
{
    public SomethingHappenedEvent(object theThing, string whatHappened)
        : this(Guid.NewGuid(), theThing, whatHappened)
    { }
}

But that tries to apply the attribute to the type, which is invalid. And this is a syntax error in C#, though apparently it works in F#.

public record SomethingHappenedEvent
[JsonConstructor]
    (Guid Id, object TheThing, string WhatHappened)
{
    public SomethingHappenedEvent(object theThing, string whatHappened)
        : this(Guid.NewGuid(), theThing, whatHappened)
    { }
}

My current solution is to leave these types as plain classes and live with all the extra boilerplate. I'm also aware I can omit the custom constructor and make my callers generate the ids. This works because there's only one constructor for json.net to find. It's certainly terse! But I don't love repeating code at all the call sites, even if it is small in this case.

public record SomethingHappenedEvent(Guid Id, object TheThing, string WhatHappened) { }

FWIW it sounds like System.Text.Json has the same limitation.

Pless answered 19/12, 2020 at 1:47 Comment(3)
You could adopt the approach from this answer to JSON.net: how to deserialize without using the default constructor? by Zoltán Tamási and invoke the "most specific" constructor automatically using a custom contract resolver. However since there is no way to test if a type is a record you will need to think a bit when to use the most specific constructor.Overhaul
That's great. Once you've got the CustomContractResolver, all sorts of options become available. You could implement a [DontUseThisConstructor] attribute, for example.Pless
There's a discussion in csharplang about allowing adding attributes to the primary constructor of records. As of now, the proposal is not championed by anyone.Sightread
M
18

Firstly, you only have to do this when you create your own constructors. This is due to the fact that on instantiation it won't know which one to use.

Secondly, note that (by default) the deserializer will use the property and constructor names and overwrite the ones you omit in the actual constructor type. Furthermore, each parameter in the constructor must bind to an object property or field on deserialization. The formers can lead to subtle errors if you are not aware of them, however this is not limited solely to records.

All that aside, you had the attribute in the wrong place. In short, the attribute needs to be on the constructor.

Wildly contrived nonsensical example:

Given

public record TestRecord(Guid Id)
{
   [JsonConstructor]
   public TestRecord(object theThing, string whatHappened) : this(Guid.NewGuid())
   {
   }
}

Test

var record = new TestRecord(Guid.NewGuid());
var json = JsonConvert.SerializeObject(record,Formatting.Indented);
Console.WriteLine(json);
var otherSideRecord = JsonConvert.DeserializeObject<TestRecord>(json);

// note this paradoxically still works, because it has overwritten the ID
Console.WriteLine(record == otherSideRecord);

Ouput

{
  "Id": "2905cfaf-d13d-4df1-af83-e4dcde20d44f"
}
True

Note that the attribute also works with Text.Json

var json = JsonSerializer.Serialize(record);
var otherSideRecord = JsonSerializer.Deserialize<TestRecord>(json);
Metzgar answered 19/12, 2020 at 2:1 Comment(2)
Interesting. this actually works in the example, and probably in a lot of real-world cases. But it is a bit odd. The 2-argument constructor is the wrong one for the deserializer to use so it seems very strange to use the attribute on it. It turns out here it is actually OK, because json.net calls the Id setter afterwards, overwriting the value generated by Guid.NewGuid on the receiving side. If the 2-argument constructor is actually incorrect for some reason instead of just redundant, this would fail, but that's clearly a narrower case.Pless
@Pless indeed correct, the plumbing just needs to know which constructor to create. Note there are different uses cases here, but for yours, you just want to deserializeMetzgar
I
10

To add an attribute on a record or on the primary constructor of a class you can use the method: target like this:

[method: JsonConstructor]
public record SomethingHappenedEvent(Guid Id, object TheThing, string WhatHappened)
{
    public SomethingHappenedEvent(object theThing, string whatHappened)
        : this(Guid.NewGuid(), theThing, whatHappened)
    { }
}

I've tested this in C# 12.

Injector answered 26/11, 2023 at 14:49 Comment(2)
Thanks! This is new for C# 12; it's coming along with the broader "primary constructor" syntax. (learn.microsoft.com/en-us/dotnet/csharp/language-reference/…). In older versions the compiler gives CS9058: "Feature is not available" (as it should)Pless
@Pless I guess I got lucky that I only needed this feature shortly after upgrading my project to .NET 8 and C# 12.Injector
P
8

I ran across another option while experimenting. I'll leave it here in case it's useful to someone. You can convert the custom constructor(s) to factory method(s). This leaves only one constructor for the deserializer to find.

public record SomethingHappenedEvent(Guid Id, object TheThing, string WhatHappened)
{
    public static SomethingHappenedEvent Create(object theThing, string whatHappened)
        => new(Guid.NewGuid(), theThing, whatHappened);
}

It changes the syntax at the call sites. Just call Create instead of new:

var myEvent = SomethingHappenedEvent.Create("partyPeople", "gotDown");

Of course you could push the static factory method(s) to a separate factory object - that might be useful if your object construction has richer dependencies.

Pless answered 24/12, 2020 at 19:38 Comment(0)
L
-1

You can add a default constructor with the attribute. Json.Net will fill the properties.

If you want you can make it private so none of the other users of the class can use it by mistake. Json.Net will still find it:

public record SomethingHappenedEvent(Guid Id, object TheThing, string WhatHappened)
{
    [JsonConstructor]
    private SomethingHappenedEvent()
    { }

    public SomethingHappenedEvent(object theThing, string whatHappened)
        : this(Guid.NewGuid(), theThing, whatHappened)
    { }
}
Leah answered 12/6, 2021 at 16:0 Comment(1)
You can't do this, a constructor declared in a record with parameter list must have 'this' constructor initializer (CS8862)Alderman

© 2022 - 2024 — McMap. All rights reserved.