Solving the Dictionary JSON Serialization Puzzle in .NET

Handling custom dictionary keys in .NET JSON serialization.

Tagged with: csharp, programming

Published on

My last post Fine-Tuning JSON Serialization in .NET introduced JSON converters that can be used to serialize and deserialize types to and from JSON in .NET. The methods I described in that post work for most use cases. Unfortunately, I ran into some issues when working with dictionaries. In this post, I want to explain these problems and show how they can be solved.

To demonstrate how to serialize dictionaries to JSON, look at the following example, which only uses primitive types as keys and values that can be serialized and deserialized out of the box by the JsonSerializer provided by System.Text.Json.

using System.Collections.Generic;
using System.Text.Json;

var d = new Dictionary<string, int>()
{
    { "foo", 42 }
};

var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(d, options);

As expected, this code works, and the resulting JSON looks like this:

{
  "foo": 42
}

However, using a custom type as the key of a dictionary that should be serialized to JSON will result in a runtime exception. It will even fail when a JsonConverter<T>, implemented as described in my previous post, is used. Let’s try to serialize a dictionary that uses a custom Currency type as its key with a custom JSON converter.

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

var eur = new Currency("EUR");

var d = new Dictionary<Currency, int>(){
    { eur, 42 }
};

var options = new JsonSerializerOptions
{
    WriteIndented = true,
    Converters = { new CurrencyJsonConverter() }
};
var json = JsonSerializer.Serialize(d, options);

public sealed record Currency(string Value);

public sealed class CurrencyJsonConverter : JsonConverter<Currency>
{
    public override Currency Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options) =>
            new Currency(reader.GetString());

    public override void Write(
        Utf8JsonWriter writer,
        Currency currency,
        JsonSerializerOptions options) =>
            writer.WriteStringValue(currency.Value);
}

Running this example will result in a System.NotSupportedException with the message “The type ‘Currency’ is not a supported dictionary key using converter of type ‘CurrencyJsonConverter’.”. So it seems that the CurrencyJsonConverter is used, but the implementation of the Write method is not used to serialize the dictionary key. A look at the stack trace of the exception, provides insight into where the exception occurred and how the problem might be resolved.

at System.Text.Json.ThrowHelper.ThrowNotSupportedException_DictionaryKeyTypeNotSupported(
  Type keyType,
  JsonConverter converter)
at System.Text.Json.Serialization.JsonConverter`1.WriteAsPropertyName(
  Utf8JsonWriter writer,
  T value,
  JsonSerializerOptions options)
...

The last call of the stack trace is just a helper method that throws the exception, but the call before to the JsonConverter.WriteAsPropertyName method looks interesting. A peak at the documentation reveals that this method “Writes a dictionary key as a JSON property name.”. Furthermore, the method is marked virtual so it can be overridden in the CurrencyJsonConverter.

 public sealed class CurrencyJsonConverter : JsonConverter<Currency>
 {
     public override Currency Read(
         ref Utf8JsonReader reader,
         Type typeToConvert,
         JsonSerializerOptions options) =>
             new Currency(reader.GetString());

     public override void Write(
         Utf8JsonWriter writer,
         Currency currency,
         JsonSerializerOptions options) =>
             writer.WriteStringValue(currency.Value);

+    public override void WriteAsPropertyName(
+        Utf8JsonWriter writer,
+        Currency currency,
+        JsonSerializerOptions options) =>
+            writer.WritePropertyName(currency.Value);
 }

It’s crucial to notice that, other than the Write method, the new WriteAsPropertyName method uses the WritePropertyName method of the Utf8JsonWriter instead of WriteStringValue. Running the example from above with the updated JSON converter works and produces the following JSON:

{
  "EUR": 42
}

This is great, but what if some JSON should be deserialized to a dictionary as demonstrated below?

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

var json = """
{
  "EUR": 42
}
""";

var options = new JsonSerializerOptions
{
    Converters = { new CurrencyJsonConverter() }
};
var d = JsonSerializer.Deserialize<Dictionary<Currency, int>>(json, options);

Similar to the serialization example, the deserialization also throws a System.NotSupportedException with the message “The type ‘Currency’ is not a supported dictionary key using converter of type ‘CurrencyJsonConverter’.”. Something is still missing from the CurrencyJsonConverter. A look at the stack trace reveals a call to a JsonConverter.ReadAsPropertyName method. This method deserializes a JSON property name to a dictionary key and can be overridden, just like the JsonConverter.WriteAsPropertyName method.

 public sealed class CurrencyJsonConverter : JsonConverter<Currency>
 {
     public override Currency Read(
         ref Utf8JsonReader reader,
         Type typeToConvert,
         JsonSerializerOptions options) =>
             new Currency(reader.GetString());

     public override void Write(
         Utf8JsonWriter writer,
         Currency currency,
         JsonSerializerOptions options) =>
             writer.WriteStringValue(currency.Value);

     public override void WriteAsPropertyName(
         Utf8JsonWriter writer,
         Currency currency,
         JsonSerializerOptions options) =>
             writer.WritePropertyName(currency.Value);

+    public override Currency ReadAsPropertyName(
+        ref Utf8JsonReader reader,
+        Type typeToConvert,
+        JsonSerializerOptions options) =>
+            Read(ref reader, typeToConvert, options);
 }

In the override of the ReadAsPropertyName method, the already implemented Read method of the CurrencyJsonConverter can be used. With the updated CurrencyJsonConverter, the previously introduced deserialization example runs without issues.

I didn’t expect any problems when serializing and deserializing dictionaries to JSON, and was surprised to encounter them, although I was using JSON converters. After some debugging and digging into the documentation, finding the solution wasn’t as cumbersome as I initially anticipated. Simply implementing the methods WriteAsPropertyNameand ReadAsPropertyName on a custom JSON converter makes it possible to use custom types as keys for dictionaries that are serialized to or deserialized from JSON. At last, I hope my findings are valuable for people struggling with the same issues.