System.Text.Json – How to customize serialization with JsonConverter

Most of the time JsonSerializer will get you want you want. You can pass in options to control serialization to a certain extent. But sometimes you’ll run into scenarios where you need to customize how it handles serialization for a specific type.

This is where JsonConverter comes in. To customize serialization for a specific type, you can subclass JsonConverter and then implement Read/Write based on your scenario.

public class NullableDateTimeConverter : JsonConverter<DateTime?> { public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { //deserialize JSON into a DateTime? return null; } public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) { //serialize a DateTime? object } }
Code language: C# (cs)

To use this custom converter, you add it to JsonSerializarOptions.Converters, then pass the options in when you’re using JsonSerializer, like this:

var message = new Message() { Text = "Executed PayCustomer command", SentAt = DateTime.UtcNow }; var options = new JsonSerializerOptions(); options.Converters.Add(new NullableDateTimeConverter()); var json = JsonSerializer.Serialize(message, options);
Code language: C# (cs)

When JsonSerializer encounters a property of the type that your custom converter handles, it’ll delegate serialization to your converter. In the above example, JsonSerializer would call NullableDateTimeConverter.Write() when it encounters the Message.SentAt property, because it is the type that this converter handles (DateTime?).

In this article, I’ll show how to implement the JsonConvert.Read()/Write() methods.

Note: This article refers to using the built-in System.Text.Json classes. If you’re using Newtonsoft, you can customize serialization using a nearly identical approach (but using Newtonsoft classes instead).

Keep it simple – make your JsonConverter very specific

You’ve heard the saying – “Don’t reinvent the wheel.” This applies here: try to leverage JsonSerializer as much possible and only create your own converter when you have no other options.

With that said, when you create your own converter, try to be very specific by following these guidelines:

  • Make your converter handle a very specific type.
  • Only pass in the converter to JsonSerializer when you will definitely need it.
  • If you only need it for serialization, only implement JsonConvert.Write().
  • If you only need it for deserialization, only implement JsonConvert.Read().

Let’s see how these guidelines would be applied to a real world scenario. Let’s say I have the LogMessage class shown below. I need to serialize this and save it to the log. To avoid the JsonException: Object cycle detected problem, when I serialize the Exception property, I only want to write the Exception.Message property.

public class LogMessage { public string Text { get; set; } public Exception Exception { get; set; } }
Code language: C# (cs)

I would apply the “be specific” guidelines by creating a JsonConverter like this:

public class LogMessageExceptionJsonConverter : JsonConverter<Exception> { public override Exception Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { //Not used in deserialization return null; } public override void Write(Utf8JsonWriter writer, Exception value, JsonSerializerOptions options) { writer.WriteStringValue(value.Message); } }
Code language: C# (cs)

This specifically handles the Exception type and only has Write() implemented (because it only handles serialization, not deserialization).

Next, I would pass this into JsonSerializer only when serializing LogMessage objects, like this:

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

By being very specific, you avoid surprises and can keep the JsonConverter logic as simple as possible.

How to implement JsonConverter.Write()

In JsonConverter.Write() you use Utf8JsonWriter to serialize the passed in object.

JSON has three types of properties: values, objects, and arrays. I’ll show how to use Utf8JsonWriter to write each type of property.

I have the following Message object that I want to serialize:

var message = new Message() { From = new Person() { Name = "Albert Einstein" }, To = new Person() { Name = "Isaac Newton" }, SentAt = new DateTime(year: 1687, month: 7, day: 4), Lines = new List<string>() { "Dear Newton:", "I've cracked time travel.", "I know you're going to publish Principia (great book, I read it a few years ago) tomorrow.", "I want to discuss a few things with you first.", "Let's meet in the cafe tomorrow at 7 am.", "Signed, Al", "PS: Naturally you won't believe I'm from the future, so I've attached today's winning lottery numbers." }, WinningNumbers = new List<int>() { 1, 2, 3, 5, 8, 13 } };
Code language: C# (cs)

I would write the JsonConverter like this:

public override void Write(Utf8JsonWriter writer, Message value, JsonSerializerOptions options) { writer.WriteStartObject(); writer.WriteString(nameof(Message.SentAt), value.SentAt.ToString("M/d/yyyy")); writer.WriteStartObject(nameof(Message.To)); writer.WriteString(nameof(Person.Name), value.To?.Name); writer.WriteEndObject(); writer.WriteStartObject(nameof(Message.From)); writer.WriteString(nameof(Person.Name), value.From?.Name); writer.WriteEndObject(); writer.WriteStartArray(nameof(Message.Lines)); value.Lines?.ForEach(line => writer.WriteStringValue(line)); writer.WriteEndArray(); writer.WriteStartArray(nameof(Message.WinningNumbers)); value.WinningNumbers?.ForEach(line => writer.WriteNumberValue(line)); writer.WriteEndArray(); writer.WriteEndObject(); }
Code language: C# (cs)

This generates the following JSON:

{ "SentAt": "7/4/1687", "To": { "Name": "Isaac Newton" }, "From": { "Name": "Albert Einstein" }, "Lines": [ "Dear Newton:", "I\u0027ve cracked time travel.", "I know you\u0027re going to publish Principia (great book, I read it a few years ago) tomorrow.", "I want to discuss a few things with you first.", "Let\u0027s meet in the cafe tomorrow at 7 am.", "Signed, Al", "PS: Naturally you won\u0027t believe I\u0027m from the future, so I\u0027ve attached today\u0027s winning lottery numbers." ], "WinningNumbers": [ 1, 2, 3, 5, 8, 13 ] }
Code language: JSON / JSON with Comments (json)

Note: By default, the encoder used by JsonSerializer encodes almost everything. In the JSON above, you can see it encoded the single quote mark character ‘ as \u0027. You can control which characters it encodes if you don’t want the default behavior.

I’ll show how to write each type of JSON property below.

Write a JSON value

You can write strings, numbers, and bools like this:

writer.WriteString("Date", DateTime.Now); writer.WriteNumber("Number", 1); writer.WriteBoolean("Bool", true);
Code language: C# (cs)

This generates the following JSON:

{ "Date": "2021-01-16T10:16:03.719736-05:00", "Number": 1, "Bool": true }
Code language: JSON / JSON with Comments (json)

Write a JSON object

To write objects, you call WriteStartObject(), write values/arrays/objects in between, then WriteEndObject().

writer.WriteStartObject(); writer.WriteString("Message", "Hello world"); writer.WriteEndObject();
Code language: C# (cs)

This outputs the following JSON:

{ "Message": "Hello world" }
Code language: JSON / JSON with Comments (json)

If the object you’re writing is contained in another JSON object, then you’d need to specify the object name like this:

writer.WriteStartObject("Message"); writer.WriteString("Text", "Hello world"); writer.WriteEndObject();
Code language: C# (cs)

This outputs the following:

"Message": { "Text": "Hello world" }
Code language: JSON / JSON with Comments (json)

Write a JSON array

To write an array, you call WriteStartArray(), write objects/values/arrays inside the array, then WriteEndArray(), like this:

int[] numbers = new int[] { 1, 2, 3 }; writer.WriteStartArray("List"); foreach(var n in numbers) { writer.WriteNumberValue(n); } writer.WriteEndArray();
Code language: C# (cs)

This generates the following JSON array:

{ "List": [ 1, 2, 3 ] }
Code language: JSON / JSON with Comments (json)

How to implement JsonConverter.Read()

In JsonConverter.Read() you use Utf8JsonReader to deserialize JSON into the target object.

To use Utf8JsonReader:

  • Loop through the JSON by calling reader.Read().
  • Check reader.TokenType to figure out what you’re dealing with.
  • When you’re in a value token (like a string), you use reader.GetString() to get the value.

The following code shows how to use Utf8JsonReader to do the above steps. It’s simply outputting to the console and not actually deserializing. You can use this a first step to figuring out how to deserialize the specific JSON you’re dealing with.

public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { Console.WriteLine($"TokenType={reader.TokenType}"); while (reader.Read()) { switch (reader.TokenType) { case JsonTokenType.StartObject: case JsonTokenType.EndObject: case JsonTokenType.StartArray: case JsonTokenType.EndArray: Console.WriteLine($"TokenType={reader.TokenType}"); break; case JsonTokenType.String: Console.WriteLine($"TokenType=String Value={reader.GetString()}"); break; case JsonTokenType.Number: Console.WriteLine($"TokenType=Number Value={reader.GetInt32()}"); break; case JsonTokenType.PropertyName: Console.WriteLine($"TokenType=PropertyName Value={reader.GetString()}"); break; } } return null; }
Code language: C# (cs)

When I run this against the Message JSON (shown in the JsonConverter.Write() section), it outputs the following:

TokenType=StartObject TokenType=PropertyName Value=SentAt TokenType=String Value=7/4/1687 TokenType=PropertyName Value=To TokenType=StartObject TokenType=PropertyName Value=Name TokenType=String Value=Isaac Newton TokenType=EndObject TokenType=PropertyName Value=From TokenType=StartObject TokenType=PropertyName Value=Name TokenType=String Value=Albert Einstein TokenType=EndObject TokenType=PropertyName Value=Lines TokenType=StartArray TokenType=String Value=Dear Newton: TokenType=String Value=I've cracked time travel. TokenType=String Value=I know you're going to publish Principia (great book, I read it a few years ago) tomorrow. TokenType=String Value=I want to discuss a few things with you first. TokenType=String Value=Let's meet in the cafe tomorrow at 7 am. TokenType=String Value=Signed, Al TokenType=String Value=PS: Naturally you won't believe I'm from the future, so I've attached today's winning lottery numbers. TokenType=EndArray TokenType=PropertyName Value=WinningNumbers TokenType=StartArray TokenType=Number Value=1 TokenType=Number Value=2 TokenType=Number Value=3 TokenType=Number Value=5 TokenType=Number Value=8 TokenType=Number Value=13 TokenType=EndArray TokenType=EndObject
Code language: plaintext (plaintext)

This is where it really pays to make your JsonConverter deal with a very specific type. The more general-purpose you try to make the converter, the more complicated it will be.

With that said, I’ll now show some examples of how to use Utf8JsonReader to deserialize simple JSON.

Reading a single JSON value

Let’s say you’re deserializing JSON that has datetime strings in the US date format (month/day/year), like this:

{ "Name": "Bob", "SentAt": "7/4/1687" }
Code language: JSON / JSON with Comments (json)

Here’s how to add a converter that is only used on DateTime properties:

public class USDateConverter : JsonConverter<DateTime> { public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { DateTime date = DateTime.Parse(reader.GetString(), CultureInfo.GetCultureInfo("en-US")); return date; } //Write() not shown }
Code language: C# (cs)

It should be noted that when you have a converter that works on values, the reader starts at the value token. So you don’t need to call reader.Read() in this case. You simply need to call reader.GetString() (or whatever the appropriate type is).

Reading a JSON object

Let’s say you have the following JSON:

{ "SentAt": "2021-01-17T15:55:36.5153627Z", "Text": "hello world!", "Id": "00007" }
Code language: JSON / JSON with Comments (json)

You want to deserialize it into the Message class shown below with the following customizations:

  • Text should be uppercased.
  • Id is being passed in as a string with leading 0’s and it needs to be converted to an integer.
public class Message { public DateTime SentAt { get; set; } public string Text { get; set; } public int Id { get; set; } }
Code language: C# (cs)

To deserialize this JSON, you need to loop through it by calling reader.Read() and look for PropertyName tokens, and then get the value tokens and map them to the appropriate Message property, like this:

public override Message Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartObject) throw new JsonException("Expected StartObject token"); var message = new Message(); while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) return message; if (reader.TokenType != JsonTokenType.PropertyName) throw new JsonException("Expected PropertyName token"); var propName = reader.GetString(); reader.Read(); switch(propName) { case nameof(Message.Id): message.Id = Int32.Parse(reader.GetString()); break; case nameof(Message.SentAt): message.SentAt = reader.GetDateTime(); break; case nameof(Message.Text): message.Text = reader.GetString()?.ToUpper(); break; } } throw new JsonException("Expected EndObject token"); }
Code language: C# (cs)

Reading a JSON array

Let’s say you have the following JSON with an array of customer names:

{ "BannedCustomers": [ "Fry", "Leela", "Bender", "Amy", "Hermes", "Zoidberg" ] }
Code language: JSON / JSON with Comments (json)

When you deserialize this, you want to load the customer names into a case insensitive HashSet.

When your converter handles an enumerable property, like HashSet, the reader starts at the StartArray token. To loop through the array items, you call reader.Read() + reader.GetString() (or the appropriate type) until you run into the EndArray token, like this:

public override HashSet<string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartArray) throw new JsonException("Expected StartArray token"); var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase); while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { set.Add(reader.GetString()); } return set; }
Code language: C# (cs)

Leave a Comment