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.

To download the source code for this article, you can visit our GitHub repository.

Let’s start.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

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.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!