DEV Community

loading...

[C #] Some scenarios for deserializing a JSON to a type with read-only properties by "System.Text.Json"

jsakamoto
Microsoft MVP for Visual Studio and Development Tech. (prefer C#, .NET Core, ASP.NET Core, Azure Web Apps, TypeScript, and Blazor WebAssembly App!)
・3 min read

Recently I feel that using the System.Text.Json library for serializing/deserializing with JSON is increasing in C# programming.

And using immutable object - that has read-only properties, and those properties are initialized in a constructor - is also increasing, I feel too.
For example, like the C# class as below:

class PersonClass
{
  public string? Name { get; }

  public int Age { get; }

  public PersonClass(string name, int age)
  {
    Name = name;
    Age = age;
  }
}
Enter fullscreen mode Exit fullscreen mode

Of course, the Deserialize<T>() static method of the JsonSerializer class in the System.Text.Json library can deserialize a JSON to an immutable object such as the above C# class.

var json = @"{""Name"":""Taro"",""Age"":23}";
var person = JsonSerializer.Deserialize<PersonClass>(json);
// person.Name -> "Taro"
// person.Age -> 23
Enter fullscreen mode Exit fullscreen mode

The Deserialize<T>() static method will detect that the class has the constructor with arguments usable for deserializing.
So by using that constructor, the Deserialize<T>() static method can deserialize a JSON to an immutable object like that.

To the Deserialize<T>() static method can detect the constructor that can use for deserializing, that constructor must have arguments that are the same-named its properties.

If it doesn't that, the exception will throw when the Deserialize<T>() static method invoked.

public PersonClass(string foo, int age)
{
  Name = foo; // The argument name isn't the same as the property.
              // This will cause an unhandled exception.
  ...
Enter fullscreen mode Exit fullscreen mode

Deserializing will fail if the class has multiple constructors.

In some rare cases, the immutable object class has to have multiple constructors.

But if the class to be deserialized has multiple constructors, the Deserialize<T>() static method will not work expectedly.
― even if the class has the appropriate constructor overload version for deserializing.

The Deserialize<T>() static method will return the object instance with no errors, but the properties values still default value.

class PersonClass
{
  ...
  public PersonClass() { } // 👈 Add this, then...
  public PersonClass(string name, int age) {...}
}

...

var json = @"{""Name"":""Taro"",""Age"":23}";
var person = JsonSerializer.Deserialize<PersonClass>(json);
// person.Name -> null ... is not "Taro"!
// person.Age -> 0 ... is not 23!
Enter fullscreen mode Exit fullscreen mode

Because the Deserialize<T>() static method will try to use the default constructor if the class has it.

But any properties are read-only, so the properties of the object that deserialized are not written.

To resolve this problem, use the [JsonConstructor] attribute.

We can annotate the appropriate constructor for deserialization with [JsonConstructor] attribute to resolve this problem.

After doing this, the Deserialize<T>() static method will use the [JsonConstructor] annotated constructor to instantiate the object. So the deserialization will work fine as the developers expected.

class PersonClass
{
  ...
  public PersonClass() { }

  [JsonConstructor] // 👈 Adding this to the constructor for
                    //     deserialization, will resolve the problem.
  public PersonClass(string name, int age) {...}
  ...
Enter fullscreen mode Exit fullscreen mode

If the property has an init-only setter...

If the property has an init-only setter, the Deserialize<T>() static method will work fine as we expected, even if it has no [JsonConstructor] annotations.

class PersonClass
{
  public string Name { get; init; }
  public int Age { get; init; }
  public PersonClass() { }
  public PersonClass(string name, int age) {...}
  ...
Enter fullscreen mode Exit fullscreen mode

In this case, the Deserialize<T>() static method will use the no-arguments default constructor to instantiate the object.

And the Deserialize<T>() static method understands how to treat init-only setter.

So the Deserialize<T>() static method can write back the value from JSON to the init-setter only property.

If the property has a private setter...

If the property has a private setter, the Deserialize<T>() static method will not work as fine as expected.

class PersonClass
{
  public string Name { get; private set; }
  public int Age { get; private set; }
  public PersonClass() { }
  public PersonClass(string name, int age) {...}
  ...
Enter fullscreen mode Exit fullscreen mode

The Deserialize<T>() static method will not write back the value from a JSON to that property via the private setter without explicit instruction.

In this scenario, of course, we can use the [JsonConstructor] approach to resolve it.

But another way, we can also apply the [JsonInclude] attribute to the private setter property to resolve it.

If the private setter property is annotated the [JsonInclude] attribute, the Deserialize<T>() static method will write back the value from a JSON to that property via the private setter.

class PersonClass
{
  [JsonInclude] // 👈 Adding this attribute allows to write back
                //     the value from a JSON to this property.
  public string Name { get; private set; }

  [JsonInclude]
  public int Age { get; private set; }

  public PersonClass() { }
  public PersonClass(string name, int age) {...}
  ...
Enter fullscreen mode Exit fullscreen mode

A little something extra

When using the "constructor initialized read-only properties" implementation pattern, considering using the "record" type is also one of a good option, I think.

Happy Coding! :)

Discussion (6)

Collapse
jeikabu profile image
jeikabu

This is why I've been wary of some of the recent C# changes. I understand the problems they're solving, but there's starting to be too many ways to do things. I feel the same about C++; there's a bunch of things you can do, but then you need a resource like "C++ FAQ" to detail what you should do.

Surprising or unexpected results are rarely desirable.

Collapse
abhishektripathi profile image
Abhishek Tripathi

I feel it too but a language must evolve with time. While evolving, it has two choices. Either it can introduce a breaking change which will lead to chaos for large systems trying to use the latest and the greatest, and the second approach is that it retains the old behavior unless it is a technical obstacle in moving forward. I do understand with this growth, now there is plenty more at the language level one must learn and must be aware which situation demands for which approach.

Collapse
j_sakamoto profile image
jsakamoto Author

I want to be to respect other's opinions, and also I partially agree with what you said, but you are too much afraid of it, I feel.

Pretty much cases, "constructor initialized read-only properties" immutable object pattern - and this is a very standard and classical pattern in C#, I think - works fine with "System.Text.Json" without additional coding.

And, the "no constructor and init-only properties" immutable object pattern - this is a modern pattern - will also work fine with "System.Text.Json" too. Of course, it doesn't require additional coding.

In this article, I just explained rare cases for someone who runs into the JSON deserialization problem, such as the class has multiple constructors.

I agree that there are too many ways to do something in recent C# programming, but recent C# programmers will use modern patterns, so the use cases of the classical ways will decrease.

For example, the "anonymous delegate" feature is still alive in the newest C#, but we usually use "lambda expression" instead of it today, so recent C# programmers may not know "anonymous delegate".

So, in my opinion, I'm optimistic about this C# programming topic. 😊

Collapse
sharpninja profile image
The Sharp Ninja

This is not a useful way of thinking. Everything evolves and sometimes introduction of new language features, such as init properties, shines light on the inadequacies of old techniques. In this case, private setters or no setter has been part of C# frim the beginning. Having a constructor to initialize read-inly properties has been common since day one. Auto-properties and object initializers caused a big shift in thinking and it became normal to have no constructors at all. Init setters fixes the obvious issue with that. So now you have code bases with three techniques to create a read-only class and serializers need to handle all of the. The fact that adding two attribures allows all use cases to be covered speaks volumes about the foresight of the team building C# in the 1990s.

Collapse
arahaan profile image
AraHaan

I love how when I have something like this:

public record Person
{
    public string Name { get; init; }
    public int Age { get; init; }
    public string Address { get; init; }
    public string Pin { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

However when I need to deserialize data from json with any value, for some reason it's always the default value.
oddly enough it never does that with CsWin32 and for some reason works when they do something like that though.

Collapse
arahaan profile image
AraHaan

Interesting it still fails when I add 2 dummy ctors to it as well.