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

By default, System.Text.Json.JsonSerializer only serializes public properties. If you want to serialize non-public properties, you have two options:

  • Create a custom converter and use reflection to get the non-public properties.
  • Use the JsonInclude attribute to enable (de)serialization of public properties with non-public accessors (not the same as non-public properties).

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

Updated 2022-02-22 to explain the new JsonInclude attribute added in .NET 5.

Write a custom converter to serialize non-public properties

When the built-in System.Text.Json functionality doesn’t fully support what you’re trying to do, you can write a custom converter. In this case, when you want to (de)serialize non-public properties, you can write a custom converter to (de)serialize all of the properties you want – including non-public ones.

Here’s an example of a custom converter that gets 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(); } }
Code language: C# (cs)

Use the custom converter by adding it to JsonSerializerOptions.Converters and passing the options in when serializing, like this:

var options = new JsonSerializerOptions(); options.Converters.Add(new CustomPersonConverter()); var json = JsonSerializer.Serialize(person, options);
Code language: C# (cs)

In this article, I’ll show a full example of serializing and deserializing non-public properties.

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

Let’s say we want to serialize and deserialize SystemEvent objects, and we want to include all non-public properties. SystemEvent has the following definition:

public class SystemEvent { public string Name { get; set; } internal DateTimeOffset HappenedAt { get; set; } public SystemEvent() { HappenedAt = DateTimeOffset.Now; } }
Code language: C# (cs)

Note: This is assuming the internal property can’t simply be changed to a public property. If you can do that in your situation, you probably wouldn’t be reading this.

Custom converter

The following custom converter uses reflection to get all of SystemEvent’s properties, public and non-public.

In the constructor, it’s using reflection to lookup all the properties. It caches these in a dictionary to save on lookup costs during deserialization.

Write() serializes by looping over the reflected properties and writing their values with the Utf8JsonWriter object.

Read() deserializes by looping through the JSON properties and matching them to the reflected properties. It uses the reflected properties to assign the value from the Utf8JsonReader object to the SystemEvent object.

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() { //Case-insensitive property names because JSON properties may be differently cased than the property names 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"); } }
Code language: C# (cs)

When writing custom converters, it’s a good idea to make it very specific to the target type you’re converting. In this case, it’s converting the SystemEvent class. This is why this is only dealing with string and DateTimeOffset properties. If you try to make the converter too generic, then it’ll get very complex, very quickly.

It should be noted that this is using a case-insensitive dictionary. This is because JSON properties could be cased differently than the properties in the class. For example, the SystemEvent.Name could be “name” in the JSON string.

Using the custom converter

To use the custom converter, add it to JsonSerializerOptions.Converters and pass the options in while serializing and deserializing.

The following example shows it serializing:

var systemEvent = new SystemEvent() { Name = "Meltdown" }; var options = new JsonSerializerOptions(); options.Converters.Add(new CustomSystemEventConverter()); var json = JsonSerializer.Serialize(systemEvent, options);
Code language: C# (cs)

This outputs the following JSON:

{"Name":"Meltdown","HappenedAt":"2021-07-13T10:52:53.9599698-04:00"}
Code language: plaintext (plaintext)

Then deserialize this JSON, and passing in the custom converter, with the following:

var options = new JsonSerializerOptions(); options.Converters.Add(new CustomSystemEventConverter()); var sysEvent = JsonSerializer.Deserialize<SystemEvent>(json, options);
Code language: C# (cs)

Because the custom converter is being used, it’s able to map the HappenedAt property in the JSON string to the internal property in the SystemEvent object.

Using the JsonInclude attribute

In .NET 5, the JsonInclude attribute was added. This allows you to enable (de)serialization on public properties with non-public accessors. Here’s an example of applying JsonInclude to a property with a private setter:

using System.Text.Json.Serialization; public class SystemEvent { public string Name { get; set; } [JsonInclude] public DateTimeOffset HappenedAt { get; private set; } }
Code language: C# (cs)

To show this working, I’ll show an example of deserializing the following JSON:

{ "Name": "Overload", "HappenedAt": "2022-02-22T07:42:15.8963892-05:00" }
Code language: JSON / JSON with Comments (json)

Here’s the deserialization:

var sysEvent = JsonSerializer.Deserialize<SystemEvent>(json); Console.WriteLine(sysEvent.HappenedAt);
Code language: C# (cs)

Here’s what it outputs:

2/22/2022 7:42:15 AM -05:00
Code language: plaintext (plaintext)

It successfully populated the HappendAt property, which has a private setter.

Can only apply JsonInclude to public properties

JsonInclude only helps you (de)serialize public properties with non-public accessors. You can’t apply JsonInclude to non-public properties. This constraint is enforced at runtime.

Here’s an example of what happens when you apply JsonInclude to a non-public property:

using System.Text.Json.Serialization; public class SystemEvent { public string Name { get; set; } [JsonInclude] internal DateTimeOffset HappenedAt { get; set; } }
Code language: C# (cs)

When you go to (de)serialize, you’ll get the following runtime exception:

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

This is a very strict constraint. If you want to be able to (de)serialize any property – public or non-public – stick with the custom converter approach. That way you can freely (de)serialize any property you want.

Leave a Comment