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);
}
}
}