System.Text.Json.Dante

First thing: I am a huge fan of Microsoft, their products and especially their developers.  These people are first class engineers.

So when I tried to move from NewtonSoft to System.Text.Json (STJ), I underestimated the problems involved.  By a lot.  I'm not going to write about this in depth, because I'm not that smart.  I am going to list the things that tripped me up.

Edit: I wrote the paragraph below using a Visual Studio 2022 Release Candidate and tried to use the Azure Functions templates that were included, and almost certainly were not fully baked.

I retried it all using the Azure Function Core Tools CLI and everything worked just as expected, including all the System.Text.Json bits.

"Before I babble on, I'd like to point out that I converted my entire project to this approach only to find out that Azure Functions seems to only like Newtonsoft.Json and that ended up being a show-stopper.  At this point the decision is System.Text.Json vs Azure Functions.  And that's a shame."

What I was trying to accomplish is polymorphic deserialization of JSON that represents a class hierarchy.  Evidently, this is tricky and in .Net v6 it was quite difficult.  New features in .Net v7 sounded promising.  

Specifically, deserializing inheritance hierarchies has required a converter to figure out which concrete class it needs to create.   In NewtonSoft, they were pretty straightforward.  In STJ, not so much.  At least for me.  But I wrote/stackoverflowed some and they worked.  

And what do you know?  Breaking change in .Net 7 causes my converters to all fail.

Alrighty, then.  I'm lazy.  Let's do .Net 7.  Oh, it's only in a "release candidate" form at the moment, so I download that.

Oh, I have to have a release candidate version of Visual Studio 2022.  Download that.

Lots of supporting libraries have changed.  Download them.

I get it.  Lots of stuff to update.  That's expected.  Not really a problem.  We're ready.  

So here are my silly classes:

public abstract class Athlete
{
	public string Name { get; set; }
}

public abstract class UniformedPlayer : Athlete
{
	public string UniformColor { get; set; }
}

public class FootballPlayer : UniformedPlayer
{
	public double HelmetSize { get; set; }
}
public class SoccerPlayer : UniformedPlayer
{
	public int ShinGuardSize { get; set; }
}

public class Golfer : Athlete
{
	public int ClubLength { get; set; }
}

As you can see, I have two abstract classes and some concrete classes that inherit from various spots in the hierarchy.

To make this work without converters, you have to put some attributes at the top of the hierarchy.  Now the Athlete class looks like this:

[JsonDerivedType(typeof(FootballPlayer), typeDiscriminator: nameof(FootballPlayer))]
[JsonDerivedType(typeof(SoccerPlayer), typeDiscriminator: nameof(SoccerPlayer))]
[JsonDerivedType(typeof(Golfer), typeDiscriminator: nameof(Golfer))]
public abstract class Athlete
{
	public string Name { get; set; }
}

With this setup the STJ serializer/deserializer can determine how to inject a property (the typeDiscriminator) for you when something is serialized and remove it when it's deserialized.  Voila.

But nothing is ever free, so there are some rules:

Rule 1 - Serialize using the base class:

If you want to serialize an instance of FootballPlayer  you can do it three ways as shown below.  

Without specifying a type for the serializer:

var footballJson = JsonSerializer.Serialize(footballPlayer, options);
Not specifying a type for the serializer
{
  "HelmetSize": 10.75,
  "UniformColor": "Blue",
  "Name": "Peyton"
}
The typeDiscriminator is missing

Specifying the concrete type for the serializer:

var footballJson = JsonSerializer.Serialize<FootballPlayer>(footballPlayer, options);
Specifying a concrete type for the serializer
{
  "HelmetSize": 10.75,
  "UniformColor": "Blue",
  "Name": "Peyton"
}
The typeDiscriminator is still missing

Specifying the base type (the annotated one):

var footballJsonWithBase = JsonSerializer.Serialize<Athlete>(footballPlayer, options);
Specifying the base type for the serializer
{
  "$type": "FootballPlayer",
  "HelmetSize": 10.75,
  "UniformColor": "Blue",
  "Name": "Peyton"
}
typeDiscriminator now included

You can see in this last one that the typeDiscriminator is now in the JSON.

The catch comes when you are trying to deserialize the JSON.  If you know that the JSON contains the concrete class of FootballPlayer, then any of the three serialization techniques above work fine.

But if you don't know which type you'll be getting, and plan on doing this:

var athleteDeserialized = JsonSerializer.Deserialize<Athlete>(singlePersonJson, options);

the only one that will work is example 3.  And that's because the deserializer needs the typeDiscriminator that's supplied when you serialize with the base type specified.

This becomes important when you are handling heterogenous lists of Athelete.  And you can see this in the JSON that's produced below:

[
  {
    "$type": "FootballPlayer",
    "HelmetSize": 10.75,
    "UniformColor": "Blue",
    "Name": "Peyton"
  },
  {
    "$type": "SoccerPlayer",
    "ShinGuardSize": 12,
    "UniformColor": "Red",
    "Name": "Ronaldo"
  },
  {
    "$type": "Golfer",
    "ClubLength": 10,
    "Name": "Arnold"
  }
]
Heterogenous list of Athlete using Serialize<Athlete>

Now when you JsonSerializer.Deserialize<List<Athlete>>(jsonPayload); you will get a list of three Athlete, all properly instantiated.

Rule 2: All constructor property names must match and all properties must be present:

Let's update our classes to use constructors as shown below:

[JsonDerivedType(typeof(FootballPlayer), typeDiscriminator: nameof(FootballPlayer))]
[JsonDerivedType(typeof(SoccerPlayer), typeDiscriminator: nameof(SoccerPlayer))]
[JsonDerivedType(typeof(Golfer), typeDiscriminator: nameof(Golfer))]
public abstract class Athlete
{
	public string Name { get; init; }
	public Athlete(string name)
	{
		Name = name;
	}
}

public abstract class UniformedPlayer : Athlete
{
	public UniformedPlayer(string name, string uniformColor) : base(name)
	{
		UniformColor = uniformColor;
	}
	public string UniformColor { get; init; }
}

public class FootballPlayer : UniformedPlayer
{
	public FootballPlayer(string name, string uniformColor, double helmetSize) : base(name, uniformColor)
	{
		HelmetSize = helmetSize;
	}
	public double HelmetSize { get; init; }
}
public class SoccerPlayer : UniformedPlayer
{
	public SoccerPlayer(string name, string uniformColor, int shinGuardSize) : base(name, uniformColor)
	{
		ShinGuardSize = shinGuardSize;
	}
	public int ShinGuardSize { get; init; }
}

public class Golfer : Athlete
{
	public Golfer(string name, int clubLength) : base(name)
	{
		ClubLength = clubLength;
	}
	public int ClubLength { get; init; }
}

Everything still works just fine.  But if I change the parameter name for golfer so that it doesn't match the property name, deserialization fails:

public class Golfer : Athlete
{
	public Golfer(string name, int lengthOfClub) : base(name)
	{
		ClubLength = lengthOfClub;
	}
	public int ClubLength { get; init; }
}

It's also a problem when you pass in something you can use during initialization, but don't plan on storing.  Like this:

public class Golfer : Athlete
{
	public Golfer(string name, int clubLength, int handicap) : base(name)
	{
		ClubLength = clubLength;
		NameAndHandicap = name + handicap.ToString();
	}
	public int ClubLength { get; init; }
	public string NameAndHandicap { get;  set; }
}
Parameter that is not a property fails

You get this error:

InvalidOperationException: Each parameter in the deserialization constructor on type 'Golfer' must bind to an object property or field on deserialization. Each parameter name must match with a property or field on the object.

It will gladly include a calculated property in the payload, but in this case, the handicap parameter doesn't have an associated property and that's a problem.

You can also use the [JsonIgnore] attribute to leave something out altogether if it is, in fact, a calculated property and you don't need to have it in the JSON at all.

Where this personally bit me was passing in a complex object, extracting two properties and setting them in the constructor.  Since the complex object was not a property itself, that wouldn't fly.

Rule 3: Any property that is initialized with a value must have a public setter to be instantiated properly.

Now imagine our Athlete base class as this:

public abstract class Athlete
{
	public long CreateDateTicks { get; } = DateTimeOffset.UtcNow.Ticks;
	public string Name { get; init; }
	public Athlete(string name)
	{
		Name = name;
	}
}
Adding the CreatedDateTicks property

Since this is a readonly property, the deserializer ignores it and during deserialization it gets set to a new value.  OK, maybe that makes sense for the deserializer, but it's probably not what you wanted.

But the same thing happens when the property is marked as protected set.  In fact, if you want the deserializer to put the original value back into the property, you must include the [JsonInclude] attribute on the property, like this:

public abstract class Athlete
{
	[JsonInclude]
	public long CreateDateTicks { get; protected set; } = DateTimeOffset.UtcNow.Ticks;

	public string Name { get; init; }

	public Athlete(string name)
	{
		Name = name;
	}
}
Use the [JsonInclude] attribute for private/protected setters

[JsonInclude] does exactly what it sounds like.  But there must be a setter, private, init or protected, to achieve the result you want.

Alternatives not yet explored

As you might imagine, specifying [JsonDerivedType] attributes on a base class that's in another project won't work.  For that and other weirdness, STJ has a "contract" model that you can use.  See this for that.  Also a better blog post about it here.

I haven't tried it, but it looks like it might help - the sample documentation is a little skimpy but the blog post is more in depth.