In this article, we are going to deal with a special case of JSON processing, polymorphic serialization, and deserialization with System.Text.Json
.
The introduction of the System.Text.Json
package has given .NET developers another powerful option for JSON format handling. You can get a full description of the package here.
Let’s start.
Classes Used in the Examples
Let’s consider the following class structure. It comprises a base class (Member
) and two derived classes (Student
and Professor
):
public abstract class Member { public string? Name { get; set; } public DateTime BirthDate { get; set; } } public class Student : Member { public int RegistrationYear { get; set; } public List<string> Courses { get; set; } = new List<string>(); } public class Professor : Member { public string? Rank { get; set; } public bool IsTenured { get; set; } }
Now we can create an array of a base type Member
that contains some Student
and Professor
objects:
var members = new Member[] { new Student() { Name = "John Doe", BirthDate = new DateTime(2000, 2, 4), RegistrationYear = 2019, Courses = new List<string>() { "Algorithms", "Databases", } }, new Professor() { Name = "Jane Doe", BirthDate = new DateTime(1978, 6, 6), Rank = "Full Professor", IsTenured = true, }, new Student() { Name = "Jason Doe", BirthDate = new DateTime(2002, 7, 8), RegistrationYear = 2020, Courses = new List<string>() { "Databases" } } };
Polymorphic Serialization With System.Text.Json
Let’s try to serialize the array with the Serialize
method from the JsonSerializer
:
var options = new JsonSerializerOptions { WriteIndented = true }; var membersJson = JsonSerializer.Serialize<Member[]>(members, options);
As result, we get a JSON string that does not contain the properties defined in the derived classes:
[ { "Name": "John Doe", "BirthDate": "2000-02-04T00:00:00" }, { "Name": "Jane Doe", "BirthDate": "1978-06-06T00:00:00" }, { "Name": "Jason Doe", "BirthDate": "2002-07-08T00:00:00" } ]
We can see that the method serializes only the properties of the base class. This behavior prevents potentially sensitive data from derived classes from being serialized by accident.
There is a simple way to overcome this limitation. We can instruct the serializer to handle the members
array as an array of objects:
var membersJson = JsonSerializer.Serialize<object[]>(members, options);
The resulting JSON string now contains all the information, both from the base and derived classes:
[ { "RegistrationYear": 2019, "Courses": [ "Algorithms", "Databases" ], "Name": "John Doe", "BirthDate": "2000-02-04T00:00:00" }, { "Rank": "Full Professor", "IsTenured": true, "Name": "Jane Doe", "BirthDate": "1978-06-06T00:00:00" }, { "RegistrationYear": 2020, "Courses": [ "Databases" ], "Name": "Jason Doe", "BirthDate": "2002-07-08T00:00:00" } ]
Note the order of the serialized object properties. We get the properties from the derived class first and then the ones from the base class. But there is a way to influence the order of the property serialization, which we will see after we talk about deserialization.
Polymorphic Deserialization With System.Text.Json
In contrast to the serialization case, there is no simple way to perform deserialization (simple or polymorphic) on a JSON string. The deserializer cannot infer the appropriate type for an object from the string. But how can then the custom converter infer the correct polymorphic type from the JSON object?
One way would be to search for specific properties in the object. For example, we can search for the Courses
property that is only part of the Student
class.
Another option would be to use the name of the class itself. An additional option would be to include a discriminator property in the JSON object to identify the appropriate class.
We are going to implement the last approach in our custom converter that will parse the JSON string and create the appropriate object.
So let’s create an empty UniversityJsonConverter
class that overrides JsonConverter<T>
:
public class UniversityJsonConverter : JsonConverter<Member> { public override bool CanConvert(Type typeToConvert) => typeof(Member).IsAssignableFrom(typeToConvert); public override Member Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { } public override void Write(Utf8JsonWriter writer, Member member, JsonSerializerOptions options) { } }
Overriding the JsonConverter Read Method
Now, let’s override the Read
method that performs the deserialization. Since this is going to be a big one, we are going to separate the explanation into two parts:
public override Member Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartObject) throw new JsonException(); reader.Read(); if (reader.TokenType != JsonTokenType.PropertyName) throw new JsonException(); string? propertyName = reader.GetString(); if (propertyName != "MemberType") throw new JsonException(); reader.Read(); if (reader.TokenType != JsonTokenType.String) throw new JsonException(); var memberType = reader.GetString(); Member member; switch (memberType) { case "Student": member = new Student(); break; case "Professor": member = new Professor(); break; default: throw new JsonException(); }; (...) }
The Read
method takes as input a reference to an Utf8JsonReader
object. It uses this object to parse the JSON string, one token at a time.
The occurrence of a StartObject
or StartArray
token marks the beginning of a JSON object or array respectively. Also, the closing of an object or array is marked with an EndObject
or EndArray
token respectively.
After we make sure that we have an occurrence of the StartObject
, we read the type discriminator and create the appropriate Member
object.
Now, we can add the second part of our logic to this method:
public override Member Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { (...) while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) return member; if (reader.TokenType == JsonTokenType.PropertyName) { propertyName = reader.GetString(); reader.Read(); switch (propertyName) { case "Name": member.Name = reader.GetString(); break; case "BirthDate": member.BirthDate = reader.GetDateTime(); break; case "RegistrationYear": int registrationYear = reader.GetInt32(); if (member is Student) ((Student)member).RegistrationYear = registrationYear; else throw new JsonException(); break; case "Rank": string? rank = reader.GetString(); if (member is Professor) ((Professor)member).Rank = rank; else throw new JsonException(); break; case "IsTenured": bool isTenured = reader.GetBoolean(); if (member is Professor) ((Professor)member).IsTenured = isTenured; else throw new JsonException(); break; case "Courses": if (member is Student) { if (reader.TokenType == JsonTokenType.StartArray) { while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndArray) break; var course = reader.GetString(); if (course != null) ((Student)member).Courses.Add(course); } } } else throw new JsonException(); break; } } } throw new JsonException(); }
We parse the property name of each token in a loop. We get the name of a property by using the PropertyName
token.
Finally, depending on the property, we parse the corresponding value and copy it into the created object.
Overriding the JsonConverter Write Method
Let’s override the Write
method that performs the serialization:
public override void Write(Utf8JsonWriter writer, Member member, JsonSerializerOptions options) { writer.WriteStartObject(); if (member is Student student) { writer.WriteString("MemberType", "Student"); writer.WriteString("Name", member.Name); writer.WriteString("BirthDate", member.BirthDate); writer.WriteNumber("RegistrationYear", student.RegistrationYear); writer.WriteStartArray("Courses"); foreach(var course in student.Courses) { writer.WriteStringValue(course); } writer.WriteEndArray(); } else if (member is Professor professor) { writer.WriteString("MemberType", "Professor"); writer.WriteString("Name", member.Name); writer.WriteString("BirthDate", member.BirthDate); writer.WriteString("Rank", professor.Rank); writer.WriteBoolean("IsTenured", professor.IsTenured); } writer.WriteEndObject(); }
The Write
method also takes as an input a reference to a Utf8JsonWriter
object. Then in the implementation, it first writes the discriminator property MemberType
into the resulting JSON string, and then other properties depending on the Member
type.
Note that the Write
method enables us to create the JSON string in the order we want it.
We can now use the custom converter we have just created, by declaring it in the JsonSerializerOptions
object:
var options = new JsonSerializerOptions { Converters = { new UniversityJsonConverter() }, WriteIndented = true }; var members = new Member[] { ... } // the member array introduced at the beginning of the article var membersJson = JsonSerializer.Serialize<Member[]>(members, options); var newMembers = JsonSerializer.Deserialize<Member[]>(membersJson, options);
We can verify that the resulting newMembers
array is the same as the initial array (members
) we have started with.
Conclusion
In this article, we delved into the special case of polymorphic serialization and deserialization using the System.Text.Json package. We’ve learned how to create a custom converter to perform the conversion from a JSON string to a C# object and vice versa.
Great tip for custom serialization/deserialization with new System.Text.Json library.
Thanks!
You are most welcome Branislav. Thanks for all the support.