21

I am trying to deserialize some JSON that contains a value that is sometimes an array, and sometimes a single item. How can I do this with System.Text.Json and JsonSerializer? (This question is inspired by this question for Json.NET by Robert McLaws.)

I have received the following JSON:

[
  {
    "email": "[email protected]",
    "timestamp": 1337966815,
    "category": [
      "newuser",
      "transactional"
    ],
    "event": "open"
  },
  {
    "email": "[email protected]",
    "timestamp": 1337966815,
    "category": "olduser",
    "event": "open"
  }
]

And I want to deserialize it to a list of the following type:

class Item
{
    public string Email { get; set; }
    public int Timestamp { get; set; }
    public string Event { get; set; }

    public List<string> Category { get; set; }
}

Using the following code:

var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
var list = JsonSerializer.Deserialize<List<Item>>(json, options);

However, when I do I get the following exception:

System.Text.Json.JsonException: The JSON value could not be converted to System.String. Path: > $[1].category | LineNumber: 13 | BytePositionInLine: 25.
   at System.Text.Json.ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(Type propertyType)
   at System.Text.Json.JsonPropertyInfo.Read(JsonTokenType tokenType, ReadStack& state, Utf8JsonReader& reader)
   at System.Text.Json.JsonSerializer.ReadCore(JsonSerializerOptions options, Utf8JsonReader& reader, ReadStack& readStack)
   at System.Text.Json.JsonSerializer.ReadCore(Type returnType, JsonSerializerOptions options, Utf8JsonReader& reader)
   at System.Text.Json.JsonSerializer.Deserialize(String json, Type returnType, JsonSerializerOptions options)
   at System.Text.Json.JsonSerializer.Deserialize[TValue](String json, JsonSerializerOptions options)

The exception occurs because the value of "category" is sometimes a single string, and sometimes an array of strings. How can I deserialize such a property with System.Text.Json?

0

4 Answers 4

29

As inspired by this answer by Brian Rogers and other answers to How to handle both a single item and an array for the same property using JSON.net, you can create a generic JsonConverter<List<T>> that checks whether the incoming JSON value is an array, and if not, deserializes an item of type T and returns the item wrapped in an appropriate list. Even better, you can create a JsonConverterFactory that manufactures such a converter for all list types List<T> encountered in your serialization graph.

First, define the following converter and converter factory:

public class SingleOrArrayConverter<TItem> : SingleOrArrayConverter<List<TItem>, TItem>
{
    public SingleOrArrayConverter() : this(true) { }
    public SingleOrArrayConverter(bool canWrite) : base(canWrite) { }
}

public class SingleOrArrayConverterFactory : JsonConverterFactory
{
    public bool CanWrite { get; }

    public SingleOrArrayConverterFactory() : this(true) { }

    public SingleOrArrayConverterFactory(bool canWrite) => CanWrite = canWrite;

    public override bool CanConvert(Type typeToConvert)
    {
        var itemType = GetItemType(typeToConvert);
        if (itemType == null)
            return false;
        if (itemType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(itemType))
            return false;
        if (typeToConvert.GetConstructor(Type.EmptyTypes) == null || typeToConvert.IsValueType)
            return false;
        return true;
    }

    public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
    {
        var itemType = GetItemType(typeToConvert);
        var converterType = typeof(SingleOrArrayConverter<,>).MakeGenericType(typeToConvert, itemType);
        return (JsonConverter)Activator.CreateInstance(converterType, new object [] { CanWrite });
    }

    static Type GetItemType(Type type)
    {
        // Quick reject for performance
        if (type.IsPrimitive || type.IsArray || type == typeof(string))
            return null;
        while (type != null)
        {
            if (type.IsGenericType)
            {
                var genType = type.GetGenericTypeDefinition();
                if (genType == typeof(List<>))
                    return type.GetGenericArguments()[0];
                // Add here other generic collection types as required, e.g. HashSet<> or ObservableCollection<> or etc.
            }
            type = type.BaseType;
        }
        return null;
    }
}

public class SingleOrArrayConverter<TCollection, TItem> : JsonConverter<TCollection> where TCollection : class, ICollection<TItem>, new()
{
    public SingleOrArrayConverter() : this(true) { }
    public SingleOrArrayConverter(bool canWrite) => CanWrite = canWrite;

    public bool CanWrite { get; }

    public override TCollection Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.Null:
                return null;
            case JsonTokenType.StartArray:
                var list = new TCollection();
                while (reader.Read())
                {
                    if (reader.TokenType == JsonTokenType.EndArray)
                        break;
                    list.Add(JsonSerializer.Deserialize<TItem>(ref reader, options));
                }
                return list;
            default:
                return new TCollection { JsonSerializer.Deserialize<TItem>(ref reader, options) };
        }
    }

    public override void Write(Utf8JsonWriter writer, TCollection value, JsonSerializerOptions options)
    {
        if (CanWrite && value.Count == 1)
        {
            JsonSerializer.Serialize(writer, value.First(), options);
        }
        else
        {
            writer.WriteStartArray();
            foreach (var item in value)
                JsonSerializer.Serialize(writer, item, options);
            writer.WriteEndArray();
        }
    }
}

Then add the the converter factory to JsonSerializerOptions.Converters before deserialization:

var options = new JsonSerializerOptions
{
    Converters = { new SingleOrArrayConverterFactory() },
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
var list = JsonSerializer.Deserialize<List<Item>>(json, options);

Or add a specific converter either to options or to your data model directly using JsonConverterAttribute:

class Item
{
    public string Email { get; set; }
    public int Timestamp { get; set; }
    public string Event { get; set; }

    [JsonConverter(typeof(SingleOrArrayConverter<string>))]
    public List<string> Category { get; set; }
}

If your data model uses some other type of collection, say ObservableCollection<string>, you can apply a lower level converter SingleOrArrayConverter<TCollection, TItem> as follows:

    [JsonConverter(typeof(SingleOrArrayConverter<ObservableCollection<string>, string>))]
    public ObservableCollection<string> Category { get; set; }

Notes:

  • If you want the converter(s) to apply only during deserialization, pass canWrite: false to the parameterized constructor:

    Converters = { new SingleOrArrayConverterFactory(canWrite: false) }
    

    The converter will still get used, but will unconditionally generate a default serialization.

  • The converter is not implemented for jagged 2d or nD collections such as List<List<string>>. It is also not implemented for arrays and read-only collections.

  • According to Serializer support for easier object and collection converters #1562, because JsonConverter<T> lacks an async Read() method,

    A limitation of the existing [JsonConverter] model is that it must "read-ahead" during deserialization to fully populate the buffer up to the end up the current JSON level. This read-ahead only occurs when the async+stream JsonSerializer deserialize methods are called and only when the current JSON for that converter starts with a StartArray or StartObject token.

    Thus using this converter to deserialize potentially very large arrays may have a negative performance impact.

    As discussed in the same thread, the converter API may get redesigned in System.Text.Json - 5.0 to fully support async deserialization by converters for arrays and object, implying that this converter may benefit from being rewritten when .NET 5 (no longer labeled with "Core") is eventually released.

Demo fiddle here.

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

4 Comments

Thank you so much. This is the first working answer I've found that uses pure JSON.Net. You saved me hours of converting my project to Newtonsoft.
This should be in the core library. Should be as easy as decorating the property.
This was what I needed, by the way, does it already support async?
@Angelru - As of .NET 7 JsonConverter still doesn't have an async method; the JSON still gets preloaded before the call to Read(). It will work just fine, but use more memory because of buffering.
3

The easiest way to do it is use 'object' type. See example below

    public class Example
    {
        public string Email { get; set; }
        public int Timestamp { get; set; }
        public string Event { get; set; }
        [JsonPropertyName("category")]
        public object CategoryObjectOrArray { get; set; }
        [JsonIgnore]
        public List<string> Category
        {
            get
            {
                if (CategoryObjectOrArray is JsonElement element)
                {
                    switch (element.ValueKind)
                    {
                        case JsonValueKind.Array:
                            return JsonSerializer.Deserialize<List<string>>(element.GetRawText());
                        case JsonValueKind.String:
                            return new List<string> { element.GetString() };
                    }
                }
                return null;
            }
        }
    }

1 Comment

This solution allows CategoryObjectOrArray to be of any type, however I'd like to use compile-time typing to restrict Category to be zero, one or more strings, and nothing else.
2

For those interested, I updated the solution to be more C#12 / .net 8.0 friendly (i.e. supports the CollectionBuilderAttribute)

It's bit longer an somehow more complex, but it supports nested collection and all (generic) collection, including array and immutable collections!

Note: I kept only the factory in order to register it only once in the json options and forget about it, but it's supposed to work also using the attribute! You just have to remove the type parameter: [JsonConverter(typeof(SingleOrArrayConverter))]

/// <summary>
/// A json converter that add supports for deserializing a single item as a collection.
/// </summary>
public class SingleOrArrayConverter : JsonConverterFactory
{
    // Not supported:
    // - Non-generic system types (e.g. Array, List, HashSet, Queue, Stack, etc.)
    // - Dictionary types (e.g. Dictionary<,>, SortedDictionary<,>, etc.)
    // - System.Collections.Concurrent.BlockingCollection
    // - Types that implements IEnumerable<T> more than once (non-deterministic result)
    // - Multi-dimensional arrays

    private static readonly Dictionary<Type, Type> _implementations = new()
    {
        { typeof(IEnumerable<>), typeof(List<>) },
        { typeof(ICollection<>), typeof(List<>) },
        { typeof(IReadOnlyCollection<>), typeof(List<>) },
        { typeof(IList<>), typeof(List<>) },
        { typeof(IReadOnlyList<>), typeof(List<>) },
        { typeof(ISet<>), typeof(HashSet<>) },
        { typeof(IReadOnlySet<>), typeof(HashSet<>) },
    };

    private static readonly Type[] _activables =
    [
        // System.Collections.Generic
        typeof(HashSet<>),
        typeof(LinkedList<>),
        typeof(List<>),
        typeof(Queue<>),
        typeof(SortedSet<>),
        typeof(Stack<>),

        // System.Collections.Concurrent
        // BlockingCollection => Not supported
        typeof(ConcurrentBag<>),
        typeof(ConcurrentQueue<>),
        typeof(ConcurrentStack<>),

        // System.Collections.ObjectModel
        typeof(Collection<>),
        typeof(ObservableCollection<>),
        typeof(ReadOnlyCollection<>),
    ];

    private readonly Dictionary<Type, JsonConverter?> _converters = new(); // Cache used to avoid re-creating converter between CanConvert and CreateConverter calls.

    /// <inheritdoc />
    public override bool CanConvert(Type typeToConvert)
        => GetConverter(typeToConvert) is IConverter { CanConvert: true };

    /// <inheritdoc />
    public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
        => GetConverter(typeToConvert) ?? throw new InvalidOperationException($"Cannot create converter for type '{typeToConvert}'.");

    private JsonConverter? GetConverter(Type typeToConvert)
    {
        // Note: System.Text.Json is known to be thread safe (https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/overview#thread-safety)
        ref var converter = ref CollectionsMarshal.GetValueRefOrAddDefault(_converters, typeToConvert, out var exists);
        if (!exists)
        {
            var itemType = GetItemType(typeToConvert);
            if (itemType is not null)
            {
                var converterType = typeof(Converter<,>).MakeGenericType(typeToConvert, itemType);
                converter = (JsonConverter)Activator.CreateInstance(converterType, null)!;
            }
        }

        return converter;
    }

    private static Type? GetItemType(Type type)
    {
        if (type.IsArray && type.GetArrayRank() is 1)
        {
            return type.GetElementType();
        }

        if (type.IsPrimitive || type == typeof(string))
        {
            return null;
        }

        if (type.IsInterface && GetEnumerableTypeArgument(type) is { } elementType)
        {
            return elementType;
        }

        return type
            .GetInterfaces()
            .Select(GetEnumerableTypeArgument)
            .FirstOrDefault(itemType => itemType is not null);

        Type? GetEnumerableTypeArgument(Type @interface)
            => @interface is { IsConstructedGenericType: true, GenericTypeArguments: [{ } itemType] } && @interface.GetGenericTypeDefinition() == typeof(IEnumerable<>) ? itemType : null;
    }

    private interface IConverter
    {
        bool CanConvert { get; }
    }

    private class Converter<TCollection, TItem> : JsonConverter<TCollection>, IConverter
    {
        private delegate TCollection Factory(ReadOnlySpan<TItem> items);

        private static readonly Factory? _factory = CreateFactory();

        bool IConverter.CanConvert => _factory is not null;

        private static Factory? CreateFactory()
        {
            var tCollection = typeof(TCollection);
            var tItem = typeof(TItem);

            if (tCollection.IsArray)
            {
                return items => (TCollection)(object)items.ToArray();
            }

            if (tCollection.GetCustomAttribute<CollectionBuilderAttribute>() is { } builder)
            {
                return builder
                        .BuilderType
                        .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
                        .Select(AsFactory)
                        .SingleOrDefault(factory => factory is not null)
                    ?? throw new InvalidOperationException($"Cannot get static method '{builder.MethodName}' on builder '{builder.BuilderType}'.");

                Factory? AsFactory(MethodInfo method)
                {
                    if (method.Name != builder.MethodName)
                    {
                        return null;
                    }

                    if (method is { IsGenericMethodDefinition: true })
                    {
                        if (method.GetGenericArguments() is not { Length: 1 })
                        {
                            return null;
                        }

                        method = method.MakeGenericMethod(tItem);
                    }

                    if (method.GetParameters() is not [{ } singleParameter] || singleParameter.ParameterType != typeof(ReadOnlySpan<TItem>))
                    {
                        return null;
                    }

                    return (Factory)method.CreateDelegate(typeof(Factory));
                }
            }

            // System types supported natively by System.Text.Json serialization
            // cf. https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/supported-types#types-that-serialize-as-json-arrays
            if (tCollection is { IsConstructedGenericType: true })
            {
                // Note: This is actually only a fast-path for system collection types that support activation with an IEnumerable<T> parameter.

                var tCollectionGenDef = tCollection.GetGenericTypeDefinition();
                if (_implementations.TryGetValue(tCollectionGenDef, out var implementation))
                {
                    tCollectionGenDef = implementation;
                }

                if (_activables.Contains(tCollectionGenDef))
                {
                    return items => (TCollection)Activator.CreateInstance(tCollectionGenDef.MakeGenericType(tItem), args: [items.ToArray()])!;
                }
            }

            // Custom types that
            // - Isn't an interface or abstract.
            // - Has a parameterless constructor.
            // - Contains element types that are supported by JsonSerializer.
            // - Implements or inherits one or more of the following interfaces or classes
            //      - ConcurrentQueue<T>
            //      - ConcurrentStack<T>*
            //      - ICollection<T>
            //      - IDictionary                       ==> Not supported
            //      - IDictionary < TKey,TValue > †     ==> Not supported
            //      - IList                             ==> Not supported
            //      - IList<T>                          ==> Implements ICollection<T>
            //      - Queue                             ==> Not supported
            //      - Queue<T>
            //      - Stack*                            ==> Not supported
            //      - Stack < T > *

            // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/supported-types#deserialization-support
            if (tCollection is { IsInterface: false, IsAbstract: false } && tCollection.GetConstructor(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, System.Type.EmptyTypes) is { } ctor)
            {
                var iCollectionOfTItem = typeof(ICollection<>).MakeGenericType(tItem);
                if (tCollection.IsAssignableTo(iCollectionOfTItem))
                {
                    var add = iCollectionOfTItem.GetMethod(nameof(ICollection<object>.Add))!;
                    return items =>
                    {
                        var collection = (TCollection)ctor.Invoke(null)!;
                        foreach (var item in items)
                        {
                            add.Invoke(collection, [item]);
                        }

                        return collection;
                    };
                }

                var queueOfTItem = typeof(Queue<>).MakeGenericType(tItem);
                if (tCollection.IsAssignableTo(queueOfTItem))
                {
                    var enqueue = queueOfTItem.GetMethod(nameof(Queue<object>.Enqueue))!;
                    return items =>
                    {
                        var collection = (TCollection)ctor.Invoke(null)!;
                        foreach (var item in items)
                        {
                            enqueue.Invoke(collection, [item]);
                        }

                        return collection;
                    };
                }

                var concurrentQueueOfTItem = typeof(ConcurrentQueue<>).MakeGenericType(tItem);
                if (tCollection.IsAssignableTo(concurrentQueueOfTItem))
                {
                    var enqueue = concurrentQueueOfTItem.GetMethod(nameof(ConcurrentQueue<object>.Enqueue))!;
                    return items =>
                    {
                        var collection = (TCollection)ctor.Invoke(null)!;
                        foreach (var item in items)
                        {
                            enqueue.Invoke(collection, [item]);
                        }

                        return collection;
                    };
                }

                var stackOfTItem = typeof(Stack<>).MakeGenericType(tItem);
                if (tCollection.IsAssignableTo(stackOfTItem))
                {
                    var push = stackOfTItem.GetMethod(nameof(Stack<object>.Push))!;
                    return items =>
                    {
                        var collection = (TCollection)ctor.Invoke(null)!;
                        for (var i = items.Length - 1; i >= 0; i--)
                        {
                            push.Invoke(collection, [items[i]]);
                        }

                        return collection;
                    };
                }

                var concurrentStackOfTItem = typeof(ConcurrentStack<>).MakeGenericType(tItem);
                if (tCollection.IsAssignableTo(concurrentStackOfTItem))
                {
                    var push = concurrentStackOfTItem.GetMethod(nameof(ConcurrentStack<object>.Push))!;
                    return items =>
                    {
                        var collection = (TCollection)ctor.Invoke(null)!;
                        for (var i = items.Length - 1; i >= 0; i--)
                        {
                            push.Invoke(collection, [items[i]]);
                        }

                        return collection;
                    };
                }
            }

            return null;
        }

        public override TCollection? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (_factory is null)
            {
                throw new InvalidOperationException($"Cannot create factory for type '{typeToConvert}'.");
            }

            while (reader.TokenType is JsonTokenType.None or JsonTokenType.Comment && reader.Read());

            switch (reader.TokenType)
            {
                case JsonTokenType.Null:
                    return default;
                case JsonTokenType.False:
                case JsonTokenType.True:
                case JsonTokenType.Number:
                case JsonTokenType.String:
                case JsonTokenType.StartObject:
                    return _factory([JsonSerializer.Deserialize<TItem>(ref reader, options)!]);
                case JsonTokenType.StartArray:
                    return _factory(JsonSerializer.Deserialize<Memory<TItem>>(ref reader, options).Span);
                default:
                    throw new JsonException("Expected value or start of object / array.");
            }
        }

        public override void Write(Utf8JsonWriter writer, TCollection value, JsonSerializerOptions options)
        {
            // When serializing, we comply to the contract and always write an array.
            JsonSerializer.Serialize(writer, value, options);
        }
    }
}

Comments

1

Here is my solution for a complex object called Campaign:

Sample Json received (single object):

"campaignType": 
{
    "name": "Safety Recall Campaign",
    "campaign": 
    {
        "campaignId": "19-234",
        "campaignDescription": "Passenger Side Airbag Inflator Recall"
    }       
}    

Sample Json received (array object)

"campaignType": 
{
    "name": "Safety Recall Campaign",
    "campaign": 
    [
       {
         "campaignId": "19-076",
         "campaignDescription": "Passenger Side Airbag Inflator Recall 1"
       },
       {
         "campaignId": "19-077",
         "campaignDescription": "Passenger Side Airbag Inflator Recall 2"
       }
    ]
}

C# Classes (the converter was added to the data model directly)

public class CampaignType
{    
    public string Name { get; set; }

    [JsonConverter(typeof(SingleOrArrayConverter<Campaign>))]    
    public List<Campaign> Campaigns { get; set; }
}

public class Campaign
{
    public string CampaignId { get; set; }

    public string CampaignDescription { get; set; }
}

Finally, the converter (to deserialize from json to single-or-array object):

internal class SingleOrArrayConverter<T> : JsonConverter<List<T>>
{
    public override bool CanConvert(Type t) => t == typeof(T) || t == typeof(List<T>);

    public override List<T> Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.Null:
                return new List<T>();

            case JsonTokenType.StartArray:
                return JsonSerializer.Deserialize<List<T>>(ref reader, options);
        
            case JsonTokenType.StartObject:
            default:
                return new List<T> { JsonSerializer.Deserialize<T>(ref reader, options) };
        }
    }

    public override void Write(Utf8JsonWriter writer, List<T> value, JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }
}

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.