I'm trying to use Newtonsoft.Json to serialize and deserialize a Dictionary<(int, int), MyClass>
. Because (int, int)
has to get serialized to a string, I have to provide a custom TypeConverter
to deserialize it back to a tuple:
public class Tuple2Converter<T1, T2> : TypeConverter {
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) {
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) {
var parts = Convert.ToString(value).Trim('(').Trim(')').Split(", ");
var item1 = (T1)TypeDescriptor.GetConverter(typeof(T1)).ConvertFromInvariantString(parts[0])!;
var item2 = (T2)TypeDescriptor.GetConverter(typeof(T2)).ConvertFromInvariantString(parts[1])!;
return (item1, item2);
}
}
// ...
TypeDescriptor.AddAttributes(typeof((int, int)), new TypeConverterAttribute(typeof(Tuple2Converter<int, int>)));
var resultingObject =
JsonConvert.DeserializeObject<Dictionary<(int Q, int R), HexWithMeta>>(dictString);
However, when deserializing I now get the error:
Cannot deserialize the current JSON object (e.g. {"name":"value"}) into type 'System.ValueTuple`2[System.Int32,System.Int32]' because the type requires a JSON string value to deserialize correctly. To fix this error either change the JSON to a JSON string value or change the deserialized type so that it is a normal .NET type (e.g. not a primitive type like integer, not a collection type like an array or List) that can be deserialized from a JSON object. JsonObjectAttribute can also be added to the type to force it to deserialize from a JSON object.
It's trying to use the custom TypeConverter to convert another (int, int)
to a C# tuple, but this time, the tuple was serialized in a standard way (JSON object instead of string), because this tuple is one that exists on MyClass
, so it was serialized like:
"QR": {
"Item1": 0,
"Item2": 0
}
How can I get Newtonsoft.Json to use the custom TypeConverter when deserializing the string-encoded tuple on the Dictionary key, but not for any tuples contained within the Dictionary's serialized values?
Note that I am only globally binding my TypeConverter via TypeDescriptor.AddAttributes()
to get correct JSON serialization, I don't need to do it for other reasons.
CodePudding user response:
Even using a custom contract resolver, Json.NET doesn't have a convenient way to inject a custom method to convert dictionary keys from and to JSON property names.[1] Binding in a global TypeConverter
is the documented way to bind JSON property names to a complex dictionary key type. But, as you have found, binding in a custom TypeConverter
for a complex type will also cause that type to be serialized as a string when serialized standalone as well as when formatted as a dictionary key.
Thus if you want to use a custom string conversion logic for a specific dictionary key type (here ValueTuple<int, int>
) but not use the same logic when serializing that type standalone, you have the following options:
You could bind a
TypeConverter
globally as you are currently doing, then cancel use of theTypeConverter
via a customJsonConverter
or contract resolver as shown in this answer to Newtonsoft.JSON cannot convert model with TypeConverter attribute.You could skip using the
TypeConverter
and create a customJsonConverter
for yourDictionary<(int, int), MyClass>
.
Since binding a TypeConverter
globally via TypeDescriptor.AddAttributes
might have unexpected side-effects, and you don't particularly want to do it anyway, I recommend the custom JsonConverter
approach. Here is one that works:
public class DictionaryTupleConverter<T1, T2, TValue> : JsonConverter<Dictionary<(T1, T2), TValue>>
{
readonly TypeConverter converter1 = TypeDescriptor.GetConverter(typeof(T1)); // Cache for performance
readonly TypeConverter converter2 = TypeDescriptor.GetConverter(typeof(T2));
public override Dictionary<(T1, T2), TValue>? ReadJson(JsonReader reader, Type objectType, Dictionary<(T1, T2), TValue>? existingValue, bool hasExistingValue, JsonSerializer serializer)
{
var innerDictionary = serializer.Deserialize<Dictionary<string, TValue>>(reader);
if (innerDictionary == null)
return null;
var dictionary = existingValue ?? (Dictionary<(T1, T2), TValue>)serializer.ContractResolver.ResolveContract(objectType).DefaultCreator!();
foreach (var pair in innerDictionary)
dictionary.Add(ConvertFrom(pair.Key), pair.Value);
return dictionary;
}
public override void WriteJson(JsonWriter writer, Dictionary<(T1, T2), TValue>? value, JsonSerializer serializer) =>
serializer.Serialize(writer, value!.ToDictionary(p => ConvertTo(p.Key)!, p => p.Value));
(T1, T2) ConvertFrom(string value)
{
var parts = value.Trim('(').Trim(')').Split(",");
var item1 = (T1)converter1.ConvertFromInvariantString(parts[0].Trim())!;
var item2 = (T2)converter2.ConvertFromInvariantString(parts[1].Trim())!;
return (item1, item2);
}
string ConvertTo((T1, T2) value)
{
var s1 = converter1.ConvertToInvariantString(value.Item1)!;
var s2 = converter2.ConvertToInvariantString(value.Item2)!;
return string.Format("({0},{1})", s1, s2);
}
}
Then serialize and deserialize using the following settings:
var settings = new JsonSerializerSettings
{
Converters = { new DictionaryTupleConverter<int, int, HexWithMeta>() },
};
Notes:
I am unsure whether
ValueTuple<T1, T2>.ToString()
is locale-invariant (this github issue suggests not), so I recommend creating your own method to convert your tuples to strings that is verifiably locale-invariant.Your conversion methods for
(T1, T2)
from and to a string do not take into account the possibility that the inner strings may contain commas or parentheses. You may want to enhance the parsing and formatting logic with some sort of simple CSV parser.
Demo fiddle here.
[1] For confirmation you may check the source code for JsonSerializerInternalWriter.GetPropertyName()
, the method used to generate a JSON property name from a dictionary key. The method is not virtual and is implemented as a series of hardcoded checks with no option to inject custom logic.