Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

.NET Core Serialize Inherited Class Properties -- Not Just Base Properties When Preserving References

We have a problem when serializing using System.Text.Json.JsonSerializer.

In this example, we have three classes: Store, Employee, and Manager. It is noted that Manager inherits from Employee.

public class Employee
{
    public string Name { get; set; }

    public int Age { get; set; }
}

public class Manager : Employee
{
    public int AllowedPersonalDays { get; set; }
}

public class Store
{
    public Employee EmployeeOfTheMonth { get; set; }

    public Manager Manager { get; set; }

    public string Name { get; set; }
}

In the class Store, we have a property called EmployeeOfTheMonth. Well, as an example, suppose this property referenced the same object as the Manager property. Because EmployeeOfTheMonth is serialized first, it will ONLY serialize the Employee properties. When serializing the Manager property -- because it is second and the same object -- it will add a reference to the EmployeeOfTheMonth. When we do this, we're losing the additional property attached to the Manager, which is AllowedPersonalDays. Additionally, as you can see, it will not deserialize because -- while a Manager is an Employee -- an Employee is not a Manager.

Here's our short example:

Manager mgr = new Manager()
{
    Age = 42,
    AllowedPersonalDays = 14,
    Name = "Jane Doe",
};

Store store = new Store()
{
    EmployeeOfTheMonth = mgr,
    Manager = mgr,
    Name = "ValuMart"
};

System.Text.Json.JsonSerializerOptions options = new System.Text.Json.JsonSerializerOptions();
options.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve;

string serialized = System.Text.Json.JsonSerializer.Serialize<Store>(store, options);
var deserialized = System.Text.Json.JsonSerializer.Deserialize<Store>(serialized, options); // <-- Will through an exception per reasons stated above

If we look at the variable serialized, this is the content:

{
  "$id":"1",
  "EmployeeOfTheMonth": {
    "$id":"2",
    "Name":"Jane Doe",
    "Age":42
  },
  "Manager": {
    "$ref":"2"
  },
  "Name":"ValuMart"
}

Using System.Text.Json.JsonSerializer, how can we get the EmployeeOfTheMonth to correctly serialize as a Manager? That is, we need the serialization to look like the following:

{
  "$id":"1",
  "EmployeeOfTheMonth": {
    "$id":"2",
    "Name":"Jane Doe",
    "Age":42,
    "AllowedPersonalDays":14         <-- We need to retain this property even if the EmployeeOfTheMonth is a Manager
  },
  "Manager": {
    "$ref":"2"
  },
  "Name":"ValuMart"
}

I know I can adjust the ORDER of the properties in the Store class, but this is not an option and a very poor choice. Thank you, all.

like image 852
Trecius Veil Avatar asked Jan 29 '26 11:01

Trecius Veil


1 Answers

The documentation on writing custom converters has a very similar example (discriminating between two subclasses of a property's declared type) and could be adapted as follows:

public class EmployeeConverter : JsonConverter<Employee>
{
    enum TypeDiscriminator
    {
        Employee = 1,
        Manager = 2
    }

    private static string s_typeDiscriminatorLabel = "$TypeDiscriminator";

    public override bool CanConvert(Type typeToConvert) =>
        typeof(Employee).IsAssignableFrom(typeToConvert);

    public override Employee 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 != s_typeDiscriminatorLabel)
        {
            throw new JsonException();
        }

        reader.Read();
        if (reader.TokenType != JsonTokenType.Number)
        {
            throw new JsonException();
        }

        // Instantiate type based on type discriminator value
        TypeDiscriminator typeDiscriminator = (TypeDiscriminator)reader.GetInt32();
        Employee employee = typeDiscriminator switch
        {
            TypeDiscriminator.Employee => new Employee(),
            TypeDiscriminator.Manager => new Manager(),
            _ => throw new JsonException()
        };

        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndObject)
            {
                return employee;
            }

            if (reader.TokenType == JsonTokenType.PropertyName)
            {
                propertyName = reader.GetString();
                reader.Read();
                switch (propertyName)
                {
                    case "Name":
                        string name = reader.GetString();
                        employee.Name = name;
                        break;
                    case "Age":
                        int age = reader.GetInt32();
                        employee.Age = age;
                        break;
                    case "AllowedPersonalDays":
                        int allowedPersonalDays = reader.GetInt32();
                        if(employee is Manager manager)
                        {
                            manager.AllowedPersonalDays = allowedPersonalDays;
                        }
                        else
                        {
                            throw new JsonException();
                        }
                        break;
                }
            }
        }

        throw new JsonException();
    }

    public override void Write(
        Utf8JsonWriter writer, Employee person, JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        // Write type indicator based on whether the runtime type is Manager
        writer.WriteNumber(s_typeDiscriminatorLabel, (int)(person is Manager ? TypeDiscriminator.Manager : TypeDiscriminator.Employee));

        writer.WriteString("Name", person.Name);
        writer.WriteNumber("Age", person.Age);

        // Write Manager-ony property only if runtime type is Manager
        if(person is Manager manager)
        {
            writer.WriteNumber("AllowedPersonalDays", manager.AllowedPersonalDays);
        }

        writer.WriteEndObject();
    }
}

Add an instance of your custom converter and it should deserialize correctly:

options.Converters.Add(new EmployeeConverter());

string serialized = JsonSerializer.Serialize<Store>(store, options);
var deserialized = JsonSerializer.Deserialize<Store>(serialized, options);
string reserialized = JsonSerializer.Serialize<Store>(deserialized, options);

System.Diagnostics.Debug.Assert(serialized == reserialized, "Manager property should be retained");
like image 75
Mathias R. Jessen Avatar answered Jan 30 '26 23:01

Mathias R. Jessen



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!