13

I'm using .net core 3.1 and library System.Text.Json

How can I deserialize nested json object to Dictionary<string, object>, but the expectation is that based on json property type I'll get proper C# type:

String -> string
Number -> int/double
Object -> Dictionary<string, object>

By default - if I try to deserialize to Dictionary<string, object> - basically every object is a JsonElement. I'd like it to be of type as mentioned above.

Any idea how it can be achieved?

3
  • Could you explain better and show us some example about what you want. Commented Jan 30, 2021 at 20:39
  • 1
    You will need to write a custom JsonConverter to do that. Unlike newtonsoft it isn't implemented automatically, perhaps because Utf8JsonReader only recognizes numbers, it doesn't actually parse them to a CLR type (thereby avoiding arithmetic overflow or roundoff errors). Commented Jan 30, 2021 at 20:45
  • Deserialize to string, string then parse yourself? Commented Jan 30, 2021 at 21:00

1 Answer 1

31

In order to deserialize free-form JSON into .Net primitive types instead of JsonElement objects, you will need to write a custom JsonConverter, as no such functionality is provided by System.Text.Json out of the box.

One such converter is the following:

public class ObjectAsPrimitiveConverter : JsonConverter<object>
{
    FloatFormat FloatFormat { get; init; }
    UnknownNumberFormat UnknownNumberFormat { get; init; }
    ObjectFormat ObjectFormat { get; init; }

    public ObjectAsPrimitiveConverter() : this(FloatFormat.Double, UnknownNumberFormat.Error, ObjectFormat.Expando) { }
    public ObjectAsPrimitiveConverter(FloatFormat floatFormat, UnknownNumberFormat unknownNumberFormat, ObjectFormat objectFormat)
    {
        this.FloatFormat = floatFormat;
        this.UnknownNumberFormat = unknownNumberFormat;
        this.ObjectFormat = objectFormat;
    }
    
    public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
    {
        if (value == null)
            writer.WriteNullValue();
        else if (value.GetType() == typeof(object))
        {
            writer.WriteStartObject();
            writer.WriteEndObject();
        }
        else
        {
            JsonSerializer.Serialize(writer, value, value.GetType(), options);
        }
    }
    
    public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.Null:
                return null;
            case JsonTokenType.False:
                return false;
            case JsonTokenType.True:
                return true;
            // If you want to, you could add a heuristic that automatically recognizes and returns DateTime values here:
            //case JsonTokenType.String when reader.TryGetDateTime(out var dt):
            //  return dt;
            // Or if you would prefer not to lose time zone info, you could add automatic DateTimeOffset recognition here:
            //case JsonTokenType.String when reader.TryGetDateTimeOffset(out var dt):
            //  return dt;
            case JsonTokenType.String:
                return reader.GetString();
            case JsonTokenType.Number:
            {
                if (reader.TryGetInt32(out var i))
                    return i;
                if (reader.TryGetInt64(out var l))
                    return l;
                // BigInteger could be added here.
                if (FloatFormat == FloatFormat.Decimal && reader.TryGetDecimal(out var m))
                    return m;
                else if (FloatFormat == FloatFormat.Double && reader.TryGetDouble(out var d))
                    return d;
                using var doc = JsonDocument.ParseValue(ref reader);
                if (UnknownNumberFormat == UnknownNumberFormat.JsonElement)
                    return doc.RootElement.Clone();
                throw new JsonException(string.Format("Cannot parse number {0}", doc.RootElement.ToString()));
            }
            case JsonTokenType.StartArray:
            {
                var list = new List<object?>();
                while (reader.Read())
                {
                    switch (reader.TokenType)
                    {
                        default:
                            list.Add(Read(ref reader, typeof(object), options));
                            break;
                        case JsonTokenType.EndArray:
                            return list;
                    }
                }
                throw new JsonException();
            }
            case JsonTokenType.StartObject:
                var dict = CreateDictionary();
                while (reader.Read())
                {
                    switch (reader.TokenType)
                    {
                        case JsonTokenType.EndObject:
                            return dict;
                        case JsonTokenType.PropertyName:
                            var key = reader.GetString()!;
                            reader.Read();
                            dict.Add(key, Read(ref reader, typeof(object), options));
                            break;
                        default:
                            throw new JsonException();
                    }
                }
                throw new JsonException();
            default:
                throw new JsonException(string.Format("Unknown token {0}", reader.TokenType));
        }
    }
    
    protected virtual IDictionary<string, object?> CreateDictionary() => 
        ObjectFormat == ObjectFormat.Expando ? new ExpandoObject() : new Dictionary<string, object?>();
}

public enum FloatFormat
{
    Double,
    Decimal,
}

public enum UnknownNumberFormat
{
    Error,
    JsonElement,
}

public enum ObjectFormat
{
    Expando,
    Dictionary,
}

And to use it, deserialize to object (or dynamic if configured to use ExpandoObject) as follows:

var options = new JsonSerializerOptions
{
    Converters = { new ObjectAsPrimitiveConverter(floatFormat : FloatFormat.Double, unknownNumberFormat : UnknownNumberFormat.Error, objectFormat : ObjectFormat.Expando) },
    WriteIndented = true,
};
dynamic d = JsonSerializer.Deserialize<dynamic>(json, options);

Notes:

  • JSON allows for numbers of arbitrary precision and magnitude, while the .Net primitive numeric types do not. In situations where some JSON number cannot be parsed into a .Net primitive type, the converter provides the option to either return a JsonElement for the number, or throw an exception.

    The converter could be extended to attempt to deserialize unsupported numbers to BigInteger.

  • You can configure the converter to use double or decimal for floating point numbers, and Dictionary<string, object> or ExpandoObject for JSON objects.

Demo fiddle here.

Sign up to request clarification or add additional context in comments.

5 Comments

Great post, answer, and converter for those times apis are created in the wild west by gungho FuLl StAcK DeVoLoPeRs. +1
what about Nullable objects? specifically, DateTime?
@Aaron.S - 1) A Nullable<T> struct will always get boxed as the underlying type T when cast to object -- or null for a null nullable. So there's no special case for that in Read(). And there's no special case in Write() because I did not override HandleNull so the framework will automatically write null for them.
@Aaron.S - 2) JSON doesn't have any concept of dates or times, which is a known PITA. But in Read() you could always check to see whether a JsonTokenType.String string token happens to be parseable to a DateTime, in fact the MSFT converter in the docs here does just that.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.