How to serialize non-public properties using System.Text.Json

By FoxLearn 3/14/2025 3:30:37 AM   120
By default, System.Text.Json.JsonSerializer only serializes public properties. If you need to serialize non-public properties, you have two main options:
  1. Create a custom JSON converter and use reflection to retrieve the non-public properties.
  2. Use the JsonInclude attribute to enable (de)serialization of public properties with non-public accessors (this does not apply to non-public properties).

In this article, I’ll demonstrate both approaches for handling non-public properties.

Write a custom JSON converter to serialize non-public properties

When the built-in System.Text.Json functionality doesn’t quite meet your needs, you can create a custom JSON converter. In this case, to serialize non-public properties, you can write a custom JSON converter to (de)serialize all the properties you want including non-public ones.

Here’s an example of a custom JSON converter that handles both public and non-public properties during serialization:

using System.Text.Json;
using System.Text.Json.Serialization;
using System.Reflection;

public class CustomPersonConverter : JsonConverter<Person>
{
    public override void Write(Utf8JsonWriter writer, Person person, JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        foreach (var prop in person.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
        {
            writer.WriteString(prop.Name, prop.GetValue(person)?.ToString());
        }
        writer.WriteEndObject();
    }

    public override Person Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        // Intentionally not implemented
        throw new NotImplementedException();
    }
}

To use this custom JSON converter, add it to JsonSerializerOptions.Converters and pass the JsonSerializerOptions during serialization:

var options = new JsonSerializerOptions();
options.Converters.Add(new CustomPersonConverter());

var json = JsonSerializer.Serialize(person, options);

Example of a custom JSON converter that serializes and deserializes non-public properties

Let's say we want to serialize and deserialize all properties, including non-public ones. We’ll use the following type, SystemEvent:

public class SystemEvent
{
    public string Name { get; set; }
    internal DateTimeOffset HappenedAt { get; set; }

    public SystemEvent()
    {
        HappenedAt = DateTimeOffset.Now;
    }
}

This example assumes the internal property cannot be made public.

Custom JSON converter

The custom JSON converter uses reflection to retrieve all properties both public and non-public:

  • The constructor uses reflection to gather properties into a dictionary to avoid repeated lookups during deserialization.
  • Write() loops through the dictionary and serializes each property using Utf8JsonWriter.
  • Read() reads through the JSON properties with Utf8JsonReader and updates the object properties accordingly.
using System.Collections.Generic;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

public class CustomSystemEventConverter : JsonConverter<SystemEvent>
{
    private readonly Dictionary<string, PropertyInfo> PropertyMap;
    
    public CustomSystemEventConverter()
    {
        PropertyMap = new Dictionary<string, PropertyInfo>(StringComparer.OrdinalIgnoreCase);

        foreach(var property in typeof(SystemEvent).GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
        {
            PropertyMap.Add(property.Name, property);
        }
    }

    public override void Write(Utf8JsonWriter writer, SystemEvent systemEvent, JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        foreach(var prop in PropertyMap.Values)
        {
            if (prop.PropertyType == typeof(string))
            {
                writer.WriteString(prop.Name, prop.GetValue(systemEvent)?.ToString());
            }
            else if (prop.PropertyType == typeof(DateTimeOffset))
            {
                writer.WriteString(prop.Name, ((DateTimeOffset)prop.GetValue(systemEvent)).ToString("o"));
            }
        }
        writer.WriteEndObject();
    }

    public override SystemEvent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.StartObject)
            throw new JsonException("Expected StartObject token");

        var systemEvent = new SystemEvent();

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

            if (reader.TokenType != JsonTokenType.PropertyName)
                throw new JsonException("Expected PropertyName token");

            var propName = reader.GetString();
            reader.Read();

            if (!PropertyMap.ContainsKey(propName))
                throw new JsonException($"JSON contains a property name not found in the type. PropertyName={propName}");

            var property = PropertyMap[propName];

            if (property.PropertyType == typeof(string))
            {
                property.SetValue(systemEvent, reader.GetString());
            }
            else if (property.PropertyType == typeof(DateTimeOffset))
            {
                property.SetValue(systemEvent, reader.GetDateTimeOffset());
            }
        }

        throw new JsonException("Expected EndObject token");
    }
}

To use the custom JSON converter for serialization, you need to add it to JsonSerializerOptions.Converters:

var systemEvent = new SystemEvent() { Name = "Meltdown" };

var options = new JsonSerializerOptions();
options.Converters.Add(new CustomSystemEventConverter());

var json = JsonSerializer.Serialize(systemEvent, options);

This will serialize to:

{"Name":"Meltdown","HappenedAt":"2025-03-13T10:52:53.9599698-04:00"}

Then, to deserialize the JSON, use the same custom converter:

var sysEvent = JsonSerializer.Deserialize<SystemEvent>(json, options);

Using the JsonInclude attribute

Starting in .NET 5, the JsonInclude attribute was introduced. This enables (de)serialization for public properties with non-public accessors.

Here’s an example of using JsonInclude with a property that has a private setter:

using System.Text.Json.Serialization;

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

    [JsonInclude]
    public DateTimeOffset HappenedAt { get; private set; }
}

For deserialization, this JSON will work:

{
    "Name": "Overload",
    "HappenedAt": "2025-02-22T07:42:15.8963892-05:00"
}

Deserialization example:

var sysEvent = JsonSerializer.Deserialize<SystemEvent>(json);
Console.WriteLine(sysEvent.HappenedAt);

Output:

2/22/2025 7:42:15 AM -05:00

This shows the HappenedAt property, which has a private setter, being populated correctly.

Can only apply JsonInclude to public properties

It’s important to note that JsonInclude only works for public properties with non-public accessors. You cannot apply JsonInclude to non-public properties.

using System.Text.Json.Serialization;

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

    [JsonInclude]
    internal DateTimeOffset HappenedAt { get; set; }
}

Attempting to (de)serialize will throw the following exception:

System.InvalidOperationException: The non-public property ‘HappenedAt’ on type ‘SystemEvent’ is annotated with ‘JsonIncludeAttribute’ which is invalid.