Search code examples
c#jsonjson.netdeserialization

How to Deserialize JSON to Dictionary with non-string (e.g. FileInfo) key type


I have this rather simple test and trying to get it working for days now, without success:

using System.Drawing;
using Newtonsoft.Json;

namespace JSON_Trials;

class StackExample {
    public static void Main() {
        Dictionary<FileInfo, Point> dict_write = new() {
            {new("a_file_name.txt"), new(1, 2)},
        };

        var json_write = JsonConvert.SerializeObject(dict_write);
        File.WriteAllText("_dict.json", json_write);

        var json_read = File.ReadAllText("_dict.json");
        var dict_read = JsonConvert.DeserializeObject<Dictionary<FileInfo, Point>>(json_read);
        Console.WriteLine(dict_read.Count);
    }
}

Basically, I'm just trying to round trip the Dictionary.

In this basic form, it creates the following error:

Newtonsoft.Json.JsonSerializationException: 'Could not convert string 'a_file_name.txt' to dictionary key type 'System.IO.FileInfo'. Create a TypeConverter to convert from the string to the key type object. Path '['a_file_name.txt']', line 1, position 19.'

Just for completeness, this is the generated json in the file:

{"a_file_name.txt":"1, 2"}

I have written many TypeConverters, JsonConverters, ContractResolverthingys and such and none of these approaches work. What am I missing here? Is there a way to do this at all? It should be super easy, right? I mean FileInfo has a single string constructor and the json is in the format of a Dictionary anyways. Any hint is much appreciated even if the "solution" might not be straight forward.

Since there are 3 (three!) valid solutions now, some clarification of this particular scenario seems appropriate:

  • The Key of the Dictionary is a type of object, that has a 'single string' constructor (in this case, FileInfo(string filename)).
  • The type of the Key is not controlled (source code can not be changed, no annotations added).
  • The JSON should stay the same (no arrays).
  • The Value doesn't really matter here.

P.S.: For all versions, we can just assume the most recent one. atm: .NET 7 & C# 11

Solution

This is the final code I ended up with, thanks @Serg! I also use FullName, as the full name is close enough to a unique identifier for me :-)

using System.ComponentModel;
using System.Drawing;
using System.Globalization;
using Newtonsoft.Json;

namespace JSON_Trials;

class StackExample {
    public static void Main() {
        Dictionary<FileInfo, Point> dict_write = new() {
            {new("a_file_name.txt"), new(1, 2)},
        };

        TypeDescriptor.AddAttributes(typeof(FileInfo), new TypeConverterAttribute(typeof(FileInfoTypeConverter)));

        var json_write = JsonConvert.SerializeObject(dict_write);
        File.WriteAllText("_dict.json", json_write);

        var json_read = File.ReadAllText("_dict.json");
        var dict_read = JsonConvert.DeserializeObject<Dictionary<FileInfo, Point>>(json_read);
        Console.WriteLine(dict_read.Count);
    }
}

internal class FileInfoTypeConverter : TypeConverter {
    public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) {
        if (sourceType == typeof(string))
            return true;
        return base.CanConvertFrom(context, sourceType);
    }

    public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) {
        if (value is string str)
            return new FileInfo(str);
        return base.ConvertFrom(context, culture, value);
    }

    public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) {
        if (value is FileInfo fi)
            return fi.FullName;
        return base.ConvertTo(context, culture, value, destinationType);
    }
}

Solution

  • There are a several causes of such a behaviour.

    1. In the JSON the dictionaries may have only strings as a keys. No objects allowed there. So, when you serializing your dictionary, you see the result of FileInfo.ToString as a key. Normally, outside of dictionary, the FileInfo object is not serializable at all and JsonConvert.SerializeObject(new FileInfo("test.txt")); will throw an exception. This may be solved with custom ContractResolver which will force to process dictionary as an array which elements are key-value pairs from a dictionary. More details https://stackoverflow.com/a/25064637/2011071
    2. FileInfo is not serializable out-of-the-box, so you need to provide appropriate JsonConverter for this.

    Update: if a dictionary-like JSON format is mandatory, I only know the two-stage solution, see at the end of the post

    So, the final (simplified) solution may looks like this:

    using System.Collections;
    using System.Drawing;
    using Newtonsoft.Json;
    using Newtonsoft.Json.Linq;
    using Newtonsoft.Json.Serialization;
    
    namespace JSON_Trials;
    
    class StackExample
    {
        public static void Main()
        {
            Dictionary<FileInfo, Point> dict_write = new() 
            {
                {new("a_file_name.txt"), new(1, 2)},
            };
    
            var jsonSerializerSettings = new JsonSerializerSettings
            {
                ContractResolver = new DictionaryAsArrayResolver(),
                Converters = new List<JsonConverter>
                {
                    new FileInfoJsonConverter(),
                }
            };
    
            var json_write = JsonConvert.SerializeObject(dict_write, jsonSerializerSettings);
            File.WriteAllText("_dict.json", json_write);
    
            var json_read = File.ReadAllText("_dict.json");
            var dict_read = JsonConvert.DeserializeObject<Dictionary<FileInfo, Point>>(json_read, jsonSerializerSettings);
            Console.WriteLine(dict_read.Count);
        }
    }
    
    internal class DictionaryAsArrayResolver : DefaultContractResolver
    {
        protected override JsonContract CreateContract(Type objectType)
        {
            if (objectType
                .GetInterfaces()
                .Any(i => i == typeof(IDictionary)
                          || (i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>))))
            {
                return base.CreateArrayContract(objectType);
            }
    
            return base.CreateContract(objectType);
        }
    }
    
    public class FileInfoJsonConverter : JsonConverter
    {
        public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
        {
            var obj = JToken.FromObject(value.ToString());
            obj.WriteTo(writer);
        }
    
        public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
        {
            if (reader.TokenType != JsonToken.String)
            {
                throw new Exception("Can only read from strings.");
            }
            return new FileInfo((string)reader.Value);
        }
    
        public override bool CanRead => true;
    
        public override bool CanConvert(Type objectType) => objectType == typeof(FileInfo);
    }
    

    Update: To have JSON in dictionary-style format, the only way I know will be to deserialize into the Dictionary<string, Point first and then convert string key into the FileInfo

    var tmp = JsonConvert.DeserializeObject<Dictionary<string, Point>>(json_read);
    var dict_read = tmp.ToDictionary(kv => new FileInfo(kv.Key), kv => kv.Value);
    

    In this sample you do not need any additional ContractResolver or JsonConverter at all.

    Update 2: the other solution is to assign a TypeConverter to the FileInfo, but it can be done only globally so I can't estimate the possible side effects and can't r recommend is way in a production.

    //call once somewhere before deserialization
    TypeDescriptor.AddAttributes(typeof(FileInfo), new TypeConverterAttribute(typeof(FileInfoTypeConverter)));
    
    
    internal class FileInfoTypeConverter : TypeConverter
    {
        public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
        {
            if (sourceType == typeof(string))
            {
                return true;
            }
    
            return base.CanConvertFrom(context, sourceType);
        }
    
        public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
        {
            if (value is string str)
            {
                return new FileInfo(str);
            }
            return base.ConvertFrom(context, culture, value);
        }
    
        public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
        {
            if (value is FileInfo fi)
            {
                return fi.Name;
            }
            return base.ConvertTo(context, culture, value, destinationType);
        }
    }