diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
index a865466d5f40b8..454019db862157 100644
--- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
+++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
@@ -1217,6 +1217,7 @@ private bool IsValidDataExtensionPropertyType(ITypeSymbol type)
ProcessMemberCustomAttributes(
contextType,
memberInfo,
+ memberType,
out bool hasJsonInclude,
out string? jsonPropertyName,
out JsonIgnoreCondition? ignoreCondition,
@@ -1322,6 +1323,7 @@ private bool IsValidDataExtensionPropertyType(ITypeSymbol type)
private void ProcessMemberCustomAttributes(
INamedTypeSymbol contextType,
ISymbol memberInfo,
+ ITypeSymbol memberType,
out bool hasJsonInclude,
out string? jsonPropertyName,
out JsonIgnoreCondition? ignoreCondition,
@@ -1355,7 +1357,7 @@ private void ProcessMemberCustomAttributes(
if (converterType is null && _knownSymbols.JsonConverterAttributeType.IsAssignableFrom(attributeType))
{
- converterType = GetConverterTypeFromJsonConverterAttribute(contextType, memberInfo, attributeData);
+ converterType = GetConverterTypeFromJsonConverterAttribute(contextType, memberInfo, attributeData, memberType);
}
else if (attributeType.ContainingAssembly.Name == SystemTextJsonNamespace)
{
@@ -1657,7 +1659,7 @@ bool MatchesConstructorParameter(ParameterGenerationSpec paramSpec)
return propertyInitializers;
}
- private TypeRef? GetConverterTypeFromJsonConverterAttribute(INamedTypeSymbol contextType, ISymbol declaringSymbol, AttributeData attributeData)
+ private TypeRef? GetConverterTypeFromJsonConverterAttribute(INamedTypeSymbol contextType, ISymbol declaringSymbol, AttributeData attributeData, ITypeSymbol? typeToConvert = null)
{
Debug.Assert(_knownSymbols.JsonConverterAttributeType.IsAssignableFrom(attributeData.AttributeClass));
@@ -1669,12 +1671,33 @@ bool MatchesConstructorParameter(ParameterGenerationSpec paramSpec)
Debug.Assert(attributeData.ConstructorArguments.Length == 1 && attributeData.ConstructorArguments[0].Value is null or ITypeSymbol);
var converterType = (ITypeSymbol?)attributeData.ConstructorArguments[0].Value;
- return GetConverterTypeFromAttribute(contextType, converterType, declaringSymbol, attributeData);
+
+ // If typeToConvert is not provided, try to infer it from declaringSymbol
+ typeToConvert ??= declaringSymbol as ITypeSymbol;
+
+ return GetConverterTypeFromAttribute(contextType, converterType, declaringSymbol, attributeData, typeToConvert);
}
- private TypeRef? GetConverterTypeFromAttribute(INamedTypeSymbol contextType, ITypeSymbol? converterType, ISymbol declaringSymbol, AttributeData attributeData)
+ private TypeRef? GetConverterTypeFromAttribute(INamedTypeSymbol contextType, ITypeSymbol? converterType, ISymbol declaringSymbol, AttributeData attributeData, ITypeSymbol? typeToConvert = null)
{
- if (converterType is not INamedTypeSymbol namedConverterType ||
+ INamedTypeSymbol? namedConverterType = converterType as INamedTypeSymbol;
+
+ // Check if this is an unbound generic converter type that needs to be constructed.
+ // For open generics, we construct the closed generic type first and then validate.
+ if (namedConverterType is { IsUnboundGenericType: true } unboundConverterType &&
+ typeToConvert is INamedTypeSymbol { IsGenericType: true } genericTypeToConvert)
+ {
+ // For nested generic types like Container<>.NestedConverter<>, we need to count
+ // all type parameters from the entire type hierarchy, not just the immediate type.
+ int totalTypeParameterCount = GetTotalTypeParameterCount(unboundConverterType);
+
+ if (totalTypeParameterCount == genericTypeToConvert.TypeArguments.Length)
+ {
+ namedConverterType = ConstructNestedGenericType(unboundConverterType, genericTypeToConvert.TypeArguments);
+ }
+ }
+
+ if (namedConverterType is null ||
!_knownSymbols.JsonConverterType.IsAssignableFrom(namedConverterType) ||
!namedConverterType.Constructors.Any(c => c.Parameters.Length == 0 && IsSymbolAccessibleWithin(c, within: contextType)))
{
@@ -1682,12 +1705,105 @@ bool MatchesConstructorParameter(ParameterGenerationSpec paramSpec)
return null;
}
- if (_knownSymbols.JsonStringEnumConverterType.IsAssignableFrom(converterType))
+ if (_knownSymbols.JsonStringEnumConverterType.IsAssignableFrom(namedConverterType))
{
ReportDiagnostic(DiagnosticDescriptors.JsonStringEnumConverterNotSupportedInAot, attributeData.GetLocation(), declaringSymbol.ToDisplayString());
}
- return new TypeRef(converterType);
+ return new TypeRef(namedConverterType);
+ }
+
+ ///
+ /// Gets the total number of type parameters from an unbound generic type,
+ /// including type parameters from containing types for nested generics.
+ /// For example, Container<>.NestedConverter<> has a total of 2 type parameters.
+ ///
+ private static int GetTotalTypeParameterCount(INamedTypeSymbol unboundType)
+ {
+ int count = 0;
+ INamedTypeSymbol? current = unboundType;
+ while (current != null)
+ {
+ count += current.TypeParameters.Length;
+ current = current.ContainingType;
+ }
+ return count;
+ }
+
+ ///
+ /// Constructs a closed generic type from an unbound generic type (potentially nested),
+ /// using the provided type arguments in the order they should be applied.
+ /// Returns null if the type cannot be constructed.
+ ///
+ private static INamedTypeSymbol? ConstructNestedGenericType(INamedTypeSymbol unboundType, ImmutableArray typeArguments)
+ {
+ // Build the chain of containing types from outermost to innermost
+ var typeChain = new List();
+ INamedTypeSymbol? current = unboundType;
+ while (current != null)
+ {
+ typeChain.Add(current);
+ current = current.ContainingType;
+ }
+
+ // Reverse to go from outermost to innermost
+ typeChain.Reverse();
+
+ // Track which type arguments have been used
+ int typeArgIndex = 0;
+ INamedTypeSymbol? constructedContainingType = null;
+
+ foreach (var type in typeChain)
+ {
+ int typeParamCount = type.TypeParameters.Length;
+ INamedTypeSymbol originalDef = type.OriginalDefinition;
+
+ if (typeParamCount > 0)
+ {
+ // Get the type arguments for this level
+ var args = typeArguments.Skip(typeArgIndex).Take(typeParamCount).ToArray();
+ typeArgIndex += typeParamCount;
+
+ // Construct this level
+ if (constructedContainingType == null)
+ {
+ constructedContainingType = originalDef.Construct(args);
+ }
+ else
+ {
+ // Get the nested type from the constructed containing type
+ var nestedTypeDef = constructedContainingType.GetTypeMembers(originalDef.Name, originalDef.Arity).FirstOrDefault();
+ if (nestedTypeDef != null)
+ {
+ constructedContainingType = nestedTypeDef.Construct(args);
+ }
+ else
+ {
+ return null;
+ }
+ }
+ }
+ else
+ {
+ // Non-generic type in the chain
+ if (constructedContainingType == null)
+ {
+ constructedContainingType = originalDef;
+ }
+ else
+ {
+ // Use arity 0 to avoid ambiguity with nested types of the same name but different arity
+ var nestedType = constructedContainingType.GetTypeMembers(originalDef.Name, 0).FirstOrDefault();
+ if (nestedType == null)
+ {
+ return null;
+ }
+ constructedContainingType = nestedType;
+ }
+ }
+ }
+
+ return constructedContainingType;
}
private static string DetermineEffectiveJsonPropertyName(string propertyName, string? jsonPropertyName, SourceGenerationOptionsSpec? options)
diff --git a/src/libraries/System.Text.Json/src/Resources/Strings.resx b/src/libraries/System.Text.Json/src/Resources/Strings.resx
index 9aef3d3e90ab99..199d59b24afe8f 100644
--- a/src/libraries/System.Text.Json/src/Resources/Strings.resx
+++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx
@@ -411,6 +411,9 @@
The converter specified on '{0}' is not compatible with the type '{1}'.
+
+ The open generic converter type '{1}' specified on '{0}' cannot be instantiated because the target type is not a generic type with a matching number of type parameters.
+
The converter specified on '{0}' does not derive from JsonConverter or have a public parameterless constructor.
diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Converters.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Converters.cs
index c96e2a1c8f5da5..7da28e2d63b84e 100644
--- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Converters.cs
+++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Converters.cs
@@ -189,6 +189,22 @@ private static JsonConverter GetConverterFromAttribute(JsonConverterAttribute co
}
else
{
+ // Handle open generic converter types (e.g., OptionConverter<> on Option).
+ // If the converter type is an open generic and the type to convert is a closed generic
+ // with matching type arity, construct the closed converter type.
+ if (converterType.IsGenericTypeDefinition)
+ {
+ if (typeToConvert.IsGenericType &&
+ converterType.GetGenericArguments().Length == typeToConvert.GetGenericArguments().Length)
+ {
+ converterType = converterType.MakeGenericType(typeToConvert.GetGenericArguments());
+ }
+ else
+ {
+ ThrowHelper.ThrowInvalidOperationException_SerializationConverterOnAttributeOpenGenericNotCompatible(declaringType, memberInfo, converterType);
+ }
+ }
+
ConstructorInfo? ctor = converterType.GetConstructor(Type.EmptyTypes);
if (!typeof(JsonConverter).IsAssignableFrom(converterType) || ctor == null || !ctor.IsPublic)
{
diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs b/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs
index 74cc50340aa694..9dfe227ec48620 100644
--- a/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs
+++ b/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs
@@ -229,6 +229,18 @@ public static void ThrowInvalidOperationException_SerializationConverterOnAttrib
throw new InvalidOperationException(SR.Format(SR.SerializationConverterOnAttributeNotCompatible, location, typeToConvert));
}
+ [DoesNotReturn]
+ public static void ThrowInvalidOperationException_SerializationConverterOnAttributeOpenGenericNotCompatible(Type classType, MemberInfo? memberInfo, Type converterType)
+ {
+ string location = classType.ToString();
+ if (memberInfo != null)
+ {
+ location += $".{memberInfo.Name}";
+ }
+
+ throw new InvalidOperationException(SR.Format(SR.SerializationConverterOnAttributeOpenGenericNotCompatible, location, converterType));
+ }
+
[DoesNotReturn]
public static void ThrowInvalidOperationException_SerializerOptionsReadOnly(JsonSerializerContext? context)
{
diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs
index 0502f4d907011f..88f59426a2105e 100644
--- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs
+++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs
@@ -1024,5 +1024,145 @@ public static void PartialContextWithAttributesOnMultipleDeclarations_RuntimeBeh
Assert.Equal(3.14, deserialized2.Value);
Assert.True(deserialized2.IsActive);
}
+
+ [Theory]
+ [InlineData(42, "42")]
+ [InlineData(0, "0")]
+ public static void SupportsOpenGenericConverterOnGenericType_Int(int value, string expectedJson)
+ {
+ Option option = new Option(value);
+ string json = JsonSerializer.Serialize(option, OpenGenericConverterContext.Default.OptionInt32);
+ Assert.Equal(expectedJson, json);
+
+ Option deserialized = JsonSerializer.Deserialize>(json, OpenGenericConverterContext.Default.OptionInt32);
+ Assert.True(deserialized.HasValue);
+ Assert.Equal(value, deserialized.Value);
+ }
+
+ [Fact]
+ public static void SupportsOpenGenericConverterOnGenericType_NullValue()
+ {
+ Option option = default;
+ string json = JsonSerializer.Serialize(option, OpenGenericConverterContext.Default.OptionInt32);
+ Assert.Equal("null", json);
+
+ Option deserialized = JsonSerializer.Deserialize>("null", OpenGenericConverterContext.Default.OptionInt32);
+ Assert.False(deserialized.HasValue);
+ }
+
+ [Theory]
+ [InlineData("hello", @"""hello""")]
+ [InlineData("", @"""""")]
+ public static void SupportsOpenGenericConverterOnGenericType_String(string value, string expectedJson)
+ {
+ Option option = new Option(value);
+ string json = JsonSerializer.Serialize(option, OpenGenericConverterContext.Default.OptionString);
+ Assert.Equal(expectedJson, json);
+
+ Option deserialized = JsonSerializer.Deserialize>(json, OpenGenericConverterContext.Default.OptionString);
+ Assert.True(deserialized.HasValue);
+ Assert.Equal(value, deserialized.Value);
+ }
+
+ [Fact]
+ public static void SupportsOpenGenericConverterOnProperty()
+ {
+ var obj = new ClassWithGenericConverterOnProperty { Value = new GenericWrapper(42) };
+ string json = JsonSerializer.Serialize(obj, OpenGenericConverterContext.Default.ClassWithGenericConverterOnProperty);
+ Assert.Equal(@"{""Value"":42}", json);
+
+ var deserialized = JsonSerializer.Deserialize(json, OpenGenericConverterContext.Default.ClassWithGenericConverterOnProperty);
+ Assert.Equal(42, deserialized.Value.WrappedValue);
+ }
+
+ [JsonSerializable(typeof(Option))]
+ [JsonSerializable(typeof(Option))]
+ [JsonSerializable(typeof(ClassWithOptionProperty))]
+ [JsonSerializable(typeof(ClassWithGenericConverterOnProperty))]
+ internal partial class OpenGenericConverterContext : JsonSerializerContext
+ {
+ }
+
+ [Fact]
+ public static void SupportsNestedGenericConverterOnGenericType()
+ {
+ var value = new TypeWithNestedConverter { Value1 = 42, Value2 = "hello" };
+ string json = JsonSerializer.Serialize(value, NestedGenericConverterContext.Default.TypeWithNestedConverterInt32String);
+ Assert.Equal(@"{""Value1"":42,""Value2"":""hello""}", json);
+
+ var deserialized = JsonSerializer.Deserialize>(json, NestedGenericConverterContext.Default.TypeWithNestedConverterInt32String);
+ Assert.Equal(42, deserialized.Value1);
+ Assert.Equal("hello", deserialized.Value2);
+ }
+
+ [Fact]
+ public static void SupportsConstrainedGenericConverterOnGenericType()
+ {
+ var value = new TypeWithSatisfiedConstraint { Value = "test" };
+ string json = JsonSerializer.Serialize(value, NestedGenericConverterContext.Default.TypeWithSatisfiedConstraintString);
+ Assert.Equal(@"{""Value"":""test""}", json);
+
+ var deserialized = JsonSerializer.Deserialize>(json, NestedGenericConverterContext.Default.TypeWithSatisfiedConstraintString);
+ Assert.Equal("test", deserialized.Value);
+ }
+
+ [Fact]
+ public static void SupportsGenericWithinNonGenericWithinGenericConverter()
+ {
+ var value = new TypeWithDeeplyNestedConverter { Value1 = 99, Value2 = "deep" };
+ string json = JsonSerializer.Serialize(value, NestedGenericConverterContext.Default.TypeWithDeeplyNestedConverterInt32String);
+ Assert.Equal(@"{""Value1"":99,""Value2"":""deep""}", json);
+
+ var deserialized = JsonSerializer.Deserialize>(json, NestedGenericConverterContext.Default.TypeWithDeeplyNestedConverterInt32String);
+ Assert.Equal(99, deserialized.Value1);
+ Assert.Equal("deep", deserialized.Value2);
+ }
+
+ [Fact]
+ public static void SupportsSingleGenericLevelNestedConverter()
+ {
+ var value = new TypeWithSingleLevelNestedConverter { Value = 42 };
+ string json = JsonSerializer.Serialize(value, NestedGenericConverterContext.Default.TypeWithSingleLevelNestedConverterInt32);
+ Assert.Equal(@"{""Value"":42}", json);
+
+ var deserialized = JsonSerializer.Deserialize>(json, NestedGenericConverterContext.Default.TypeWithSingleLevelNestedConverterInt32);
+ Assert.Equal(42, deserialized.Value);
+ }
+
+ [Fact]
+ public static void SupportsAsymmetricNestedConverterWithManyParams()
+ {
+ var value = new TypeWithManyParams
+ {
+ Value1 = 1,
+ Value2 = "two",
+ Value3 = true,
+ Value4 = 4.0,
+ Value5 = 5L
+ };
+ string json = JsonSerializer.Serialize(value, NestedGenericConverterContext.Default.TypeWithManyParamsInt32StringBooleanDoubleInt64);
+ Assert.Equal(@"{""Value1"":1,""Value2"":""two"",""Value3"":true,""Value4"":4,""Value5"":5}", json);
+
+ var deserialized = JsonSerializer.Deserialize>(json, NestedGenericConverterContext.Default.TypeWithManyParamsInt32StringBooleanDoubleInt64);
+ Assert.Equal(1, deserialized.Value1);
+ Assert.Equal("two", deserialized.Value2);
+ Assert.True(deserialized.Value3);
+ Assert.Equal(4.0, deserialized.Value4);
+ Assert.Equal(5L, deserialized.Value5);
+ }
+
+ [JsonSerializable(typeof(TypeWithNestedConverter))]
+ [JsonSerializable(typeof(TypeWithSatisfiedConstraint))]
+ [JsonSerializable(typeof(TypeWithDeeplyNestedConverter))]
+ [JsonSerializable(typeof(TypeWithSingleLevelNestedConverter))]
+ [JsonSerializable(typeof(TypeWithManyParams))]
+ [JsonSerializable(typeof(int))]
+ [JsonSerializable(typeof(string))]
+ [JsonSerializable(typeof(bool))]
+ [JsonSerializable(typeof(double))]
+ [JsonSerializable(typeof(long))]
+ internal partial class NestedGenericConverterContext : JsonSerializerContext
+ {
+ }
}
}
diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/TestClasses.CustomConverters.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/TestClasses.CustomConverters.cs
index 0a4ac1a1904129..a049400c652092 100644
--- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/TestClasses.CustomConverters.cs
+++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/TestClasses.CustomConverters.cs
@@ -300,4 +300,359 @@ public enum SourceGenSampleEnum
One = 1,
Two = 2
}
+
+ // Generic converter types for testing open generic converter support
+
+ ///
+ /// A generic option type that represents an optional value.
+ /// Uses an open generic converter type.
+ ///
+ [JsonConverter(typeof(OptionConverter<>))]
+ public readonly struct Option
+ {
+ public bool HasValue { get; }
+ public T Value { get; }
+
+ public Option(T value)
+ {
+ HasValue = true;
+ Value = value;
+ }
+
+ public static implicit operator Option(T value) => new(value);
+ }
+
+ ///
+ /// Generic converter for the Option type.
+ ///
+ public sealed class OptionConverter : JsonConverter>
+ {
+ public override bool HandleNull => true;
+
+ public override Option Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ return default;
+ }
+
+ return new(JsonSerializer.Deserialize(ref reader, options)!);
+ }
+
+ public override void Write(Utf8JsonWriter writer, Option value, JsonSerializerOptions options)
+ {
+ if (!value.HasValue)
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ JsonSerializer.Serialize(writer, value.Value, options);
+ }
+ }
+
+ ///
+ /// A class that contains an Option property for testing.
+ ///
+ public class ClassWithOptionProperty
+ {
+ public string Name { get; set; }
+ public Option OptionalValue { get; set; }
+ }
+
+ ///
+ /// A wrapper type that uses an open generic converter on a property.
+ ///
+ public class GenericWrapper
+ {
+ public T WrappedValue { get; }
+
+ public GenericWrapper(T value)
+ {
+ WrappedValue = value;
+ }
+ }
+
+ ///
+ /// Generic converter for the GenericWrapper type.
+ ///
+ public sealed class GenericWrapperConverter : JsonConverter>
+ {
+ public override GenericWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ T value = JsonSerializer.Deserialize(ref reader, options)!;
+ return new GenericWrapper(value);
+ }
+
+ public override void Write(Utf8JsonWriter writer, GenericWrapper value, JsonSerializerOptions options)
+ {
+ JsonSerializer.Serialize(writer, value.WrappedValue, options);
+ }
+ }
+
+ ///
+ /// A class with a property that uses an open generic converter attribute.
+ ///
+ public class ClassWithGenericConverterOnProperty
+ {
+ [JsonConverter(typeof(GenericWrapperConverter<>))]
+ public GenericWrapper Value { get; set; }
+ }
+
+ // Tests for nested containing class with type parameters
+ // The converter is nested in a generic container class.
+ [JsonConverter(typeof(NestedConverterContainer<>.NestedConverter<>))]
+ public class TypeWithNestedConverter
+ {
+ public T1 Value1 { get; set; }
+ public T2 Value2 { get; set; }
+ }
+
+ public class NestedConverterContainer
+ {
+ public sealed class NestedConverter : JsonConverter>
+ {
+ public override TypeWithNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException();
+
+ var result = new TypeWithNestedConverter();
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ throw new JsonException();
+
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ if (propertyName == "Value1")
+ result.Value1 = JsonSerializer.Deserialize(ref reader, options)!;
+ else if (propertyName == "Value2")
+ result.Value2 = JsonSerializer.Deserialize(ref reader, options)!;
+ }
+
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, TypeWithNestedConverter value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("Value1");
+ JsonSerializer.Serialize(writer, value.Value1, options);
+ writer.WritePropertyName("Value2");
+ JsonSerializer.Serialize(writer, value.Value2, options);
+ writer.WriteEndObject();
+ }
+ }
+ }
+
+ // Tests for type parameters with constraints that are satisfied
+ [JsonConverter(typeof(ConverterWithClassConstraint<>))]
+ public class TypeWithSatisfiedConstraint
+ {
+ public T Value { get; set; }
+ }
+
+ public sealed class ConverterWithClassConstraint : JsonConverter> where T : class
+ {
+ public override TypeWithSatisfiedConstraint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException();
+
+ var result = new TypeWithSatisfiedConstraint();
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ throw new JsonException();
+
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ if (propertyName == "Value")
+ result.Value = JsonSerializer.Deserialize(ref reader, options)!;
+ }
+
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, TypeWithSatisfiedConstraint value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("Value");
+ JsonSerializer.Serialize(writer, value.Value, options);
+ writer.WriteEndObject();
+ }
+ }
+
+ // Tests for generic within non-generic within generic: Outer<>.Middle.Inner<>
+ [JsonConverter(typeof(OuterGeneric<>.MiddleNonGeneric.InnerConverter<>))]
+ public class TypeWithDeeplyNestedConverter
+ {
+ public T1 Value1 { get; set; }
+ public T2 Value2 { get; set; }
+ }
+
+ public class OuterGeneric
+ {
+ public class MiddleNonGeneric
+ {
+ public sealed class InnerConverter : JsonConverter>
+ {
+ public override TypeWithDeeplyNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException();
+
+ var result = new TypeWithDeeplyNestedConverter();
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ throw new JsonException();
+
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ if (propertyName == "Value1")
+ result.Value1 = JsonSerializer.Deserialize(ref reader, options)!;
+ else if (propertyName == "Value2")
+ result.Value2 = JsonSerializer.Deserialize(ref reader, options)!;
+ }
+
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, TypeWithDeeplyNestedConverter value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("Value1");
+ JsonSerializer.Serialize(writer, value.Value1, options);
+ writer.WritePropertyName("Value2");
+ JsonSerializer.Serialize(writer, value.Value2, options);
+ writer.WriteEndObject();
+ }
+ }
+ }
+ }
+
+ // Tests for many generic parameters with asymmetric distribution across nesting levels: Level1<,,>.Level2<>.Level3<>
+ [JsonConverter(typeof(Level1<,,>.Level2<>.Level3Converter<>))]
+ public class TypeWithManyParams
+ {
+ public T1 Value1 { get; set; }
+ public T2 Value2 { get; set; }
+ public T3 Value3 { get; set; }
+ public T4 Value4 { get; set; }
+ public T5 Value5 { get; set; }
+ }
+
+ public class Level1
+ {
+ public class Level2
+ {
+ public sealed class Level3Converter : JsonConverter>
+ {
+ public override TypeWithManyParams Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException();
+
+ var result = new TypeWithManyParams ();
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ throw new JsonException();
+
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ switch (propertyName)
+ {
+ case "Value1": result.Value1 = JsonSerializer.Deserialize (ref reader, options)!; break;
+ case "Value2": result.Value2 = JsonSerializer.Deserialize(ref reader, options)!; break;
+ case "Value3": result.Value3 = JsonSerializer.Deserialize(ref reader, options)!; break;
+ case "Value4": result.Value4 = JsonSerializer.Deserialize(ref reader, options)!; break;
+ case "Value5": result.Value5 = JsonSerializer.Deserialize(ref reader, options)!; break;
+ }
+ }
+
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, TypeWithManyParams value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("Value1");
+ JsonSerializer.Serialize(writer, value.Value1, options);
+ writer.WritePropertyName("Value2");
+ JsonSerializer.Serialize(writer, value.Value2, options);
+ writer.WritePropertyName("Value3");
+ JsonSerializer.Serialize(writer, value.Value3, options);
+ writer.WritePropertyName("Value4");
+ JsonSerializer.Serialize(writer, value.Value4, options);
+ writer.WritePropertyName("Value5");
+ JsonSerializer.Serialize(writer, value.Value5, options);
+ writer.WriteEndObject();
+ }
+ }
+ }
+ }
+
+ // Tests for a single generic type parameter in a nested converter (non-generic containing generic)
+ [JsonConverter(typeof(NonGenericOuter.SingleLevelGenericConverter<>))]
+ public class TypeWithSingleLevelNestedConverter
+ {
+ public T Value { get; set; }
+ }
+
+ public class NonGenericOuter
+ {
+ public sealed class SingleLevelGenericConverter : JsonConverter>
+ {
+ public override TypeWithSingleLevelNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException();
+
+ var result = new TypeWithSingleLevelNestedConverter();
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ throw new JsonException();
+
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ if (propertyName == "Value")
+ result.Value = JsonSerializer.Deserialize(ref reader, options)!;
+ }
+
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, TypeWithSingleLevelNestedConverter value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("Value");
+ JsonSerializer.Serialize(writer, value.Value, options);
+ writer.WriteEndObject();
+ }
+ }
+ }
}
diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/CompilationHelper.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/CompilationHelper.cs
index 183ed2f69c2530..ba8ee4c3cc900c 100644
--- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/CompilationHelper.cs
+++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/CompilationHelper.cs
@@ -859,6 +859,327 @@ private static void LogGeneratedCode(SyntaxTree tree, ITestOutputHelper logger)
lineWriter.WriteLine(string.Empty);
}
+ public static Compilation CreateTypesWithGenericConverterArityMismatch()
+ {
+ string source = """
+ using System;
+ using System.Text.Json;
+ using System.Text.Json.Serialization;
+
+ namespace HelloWorld
+ {
+ [JsonSerializable(typeof(TypeWithArityMismatch))]
+ internal partial class JsonContext : JsonSerializerContext
+ {
+ }
+
+ [JsonConverter(typeof(ConverterWithTwoParams<,>))]
+ public class TypeWithArityMismatch
+ {
+ public T Value { get; set; }
+ }
+
+ public sealed class ConverterWithTwoParams : JsonConverter>
+ {
+ public override TypeWithArityMismatch Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+
+ public override void Write(Utf8JsonWriter writer, TypeWithArityMismatch value, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+ }
+ }
+ """;
+
+ return CreateCompilation(source);
+ }
+
+ public static Compilation CreateTypesWithGenericConverterTypeMismatch()
+ {
+ string source = """
+ using System;
+ using System.Text.Json;
+ using System.Text.Json.Serialization;
+
+ namespace HelloWorld
+ {
+ [JsonSerializable(typeof(TypeWithConverterMismatch))]
+ internal partial class JsonContext : JsonSerializerContext
+ {
+ }
+
+ [JsonConverter(typeof(DifferentTypeConverter<>))]
+ public class TypeWithConverterMismatch
+ {
+ public T Value { get; set; }
+ }
+
+ public class DifferentType
+ {
+ public T Value { get; set; }
+ }
+
+ public sealed class DifferentTypeConverter : JsonConverter>
+ {
+ public override DifferentType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+
+ public override void Write(Utf8JsonWriter writer, DifferentType value, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+ }
+ }
+ """;
+
+ return CreateCompilation(source);
+ }
+
+ public static Compilation CreateTypesWithNestedGenericConverter()
+ {
+ string source = """
+ using System;
+ using System.Text.Json;
+ using System.Text.Json.Serialization;
+
+ namespace HelloWorld
+ {
+ [JsonSerializable(typeof(TypeWithNestedConverter))]
+ internal partial class JsonContext : JsonSerializerContext
+ {
+ }
+
+ [JsonConverter(typeof(Container<>.NestedConverter<>))]
+ public class TypeWithNestedConverter
+ {
+ public T1 Value1 { get; set; }
+ public T2 Value2 { get; set; }
+ }
+
+ public class Container
+ {
+ public sealed class NestedConverter : JsonConverter>
+ {
+ public override TypeWithNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException();
+
+ var result = new TypeWithNestedConverter();
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ throw new JsonException();
+
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ if (propertyName == "Value1")
+ result.Value1 = JsonSerializer.Deserialize(ref reader, options)!;
+ else if (propertyName == "Value2")
+ result.Value2 = JsonSerializer.Deserialize(ref reader, options)!;
+ }
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, TypeWithNestedConverter value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("Value1");
+ JsonSerializer.Serialize(writer, value.Value1, options);
+ writer.WritePropertyName("Value2");
+ JsonSerializer.Serialize(writer, value.Value2, options);
+ writer.WriteEndObject();
+ }
+ }
+ }
+ }
+ """;
+
+ return CreateCompilation(source);
+ }
+
+ public static Compilation CreateTypesWithConstrainedGenericConverter()
+ {
+ string source = """
+ using System;
+ using System.Text.Json;
+ using System.Text.Json.Serialization;
+
+ namespace HelloWorld
+ {
+ [JsonSerializable(typeof(TypeWithSatisfiedConstraint))]
+ internal partial class JsonContext : JsonSerializerContext
+ {
+ }
+
+ [JsonConverter(typeof(ConverterWithClassConstraint<>))]
+ public class TypeWithSatisfiedConstraint
+ {
+ public T Value { get; set; }
+ }
+
+ public sealed class ConverterWithClassConstraint : JsonConverter> where T : class
+ {
+ public override TypeWithSatisfiedConstraint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException();
+
+ var result = new TypeWithSatisfiedConstraint();
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ throw new JsonException();
+
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ if (propertyName == "Value")
+ result.Value = JsonSerializer.Deserialize(ref reader, options)!;
+ }
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, TypeWithSatisfiedConstraint value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("Value");
+ JsonSerializer.Serialize(writer, value.Value, options);
+ writer.WriteEndObject();
+ }
+ }
+ }
+ """;
+
+ return CreateCompilation(source);
+ }
+
+ public static Compilation CreateTypesWithDeeplyNestedGenericConverter()
+ {
+ string source = """
+ using System;
+ using System.Text.Json;
+ using System.Text.Json.Serialization;
+
+ namespace HelloWorld
+ {
+ [JsonSerializable(typeof(TypeWithDeeplyNestedConverter))]
+ internal partial class JsonContext : JsonSerializerContext
+ {
+ }
+
+ [JsonConverter(typeof(OuterGeneric<>.MiddleNonGeneric.InnerConverter<>))]
+ public class TypeWithDeeplyNestedConverter
+ {
+ public T1 Value1 { get; set; }
+ public T2 Value2 { get; set; }
+ }
+
+ public class OuterGeneric
+ {
+ public class MiddleNonGeneric
+ {
+ public sealed class InnerConverter : JsonConverter>
+ {
+ public override TypeWithDeeplyNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+
+ public override void Write(Utf8JsonWriter writer, TypeWithDeeplyNestedConverter value, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+ }
+ }
+ }
+ }
+ """;
+
+ return CreateCompilation(source);
+ }
+
+ public static Compilation CreateTypesWithNonGenericOuterGenericConverter()
+ {
+ string source = """
+ using System;
+ using System.Text.Json;
+ using System.Text.Json.Serialization;
+
+ namespace HelloWorld
+ {
+ [JsonSerializable(typeof(TypeWithNonGenericOuterConverter))]
+ internal partial class JsonContext : JsonSerializerContext
+ {
+ }
+
+ [JsonConverter(typeof(NonGenericOuter.SingleLevelConverter<>))]
+ public class TypeWithNonGenericOuterConverter
+ {
+ public T Value { get; set; }
+ }
+
+ public class NonGenericOuter
+ {
+ public sealed class SingleLevelConverter : JsonConverter>
+ {
+ public override TypeWithNonGenericOuterConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+
+ public override void Write(Utf8JsonWriter writer, TypeWithNonGenericOuterConverter value, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+ }
+ }
+ }
+ """;
+
+ return CreateCompilation(source);
+ }
+
+ public static Compilation CreateTypesWithManyParamsAsymmetricNestedConverter()
+ {
+ string source = """
+ using System;
+ using System.Text.Json;
+ using System.Text.Json.Serialization;
+
+ namespace HelloWorld
+ {
+ [JsonSerializable(typeof(TypeWithManyParams))]
+ internal partial class JsonContext : JsonSerializerContext
+ {
+ }
+
+ [JsonConverter(typeof(Level1<,,>.Level2<>.Level3Converter<>))]
+ public class TypeWithManyParams
+ {
+ public T1 Value1 { get; set; }
+ public T2 Value2 { get; set; }
+ public T3 Value3 { get; set; }
+ public T4 Value4 { get; set; }
+ public T5 Value5 { get; set; }
+ }
+
+ public class Level1
+ {
+ public class Level2
+ {
+ public sealed class Level3Converter : JsonConverter>
+ {
+ public override TypeWithManyParams Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+
+ public override void Write(Utf8JsonWriter writer, TypeWithManyParams value, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+ }
+ }
+ }
+ }
+ """;
+
+ return CreateCompilation(source);
+ }
+
private static readonly string FileSeparator = new string('=', 140);
private sealed class NumberedSourceFileWriter : TextWriter
diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs
index 5712a52072a110..b40cfb350a7290 100644
--- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs
+++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs
@@ -784,5 +784,90 @@ public partial class JsonContext : JsonSerializerContext { }
Diagnostic diagnostic = Assert.Single(effective, d => d.Id == "SYSLIB1038");
Assert.True(diagnostic.IsSuppressed);
}
+
+ [Fact]
+ public void GenericConverterArityMismatch_WarnsAsExpected()
+ {
+ Compilation compilation = CompilationHelper.CreateTypesWithGenericConverterArityMismatch();
+ JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true);
+
+ Location converterAttrLocation = compilation.GetSymbolsWithName("TypeWithArityMismatch").First().GetAttributes()[0].GetLocation();
+ INamedTypeSymbol contextSymbol = (INamedTypeSymbol)compilation.GetSymbolsWithName("JsonContext").First();
+ Location jsonSerializableAttrLocation = contextSymbol.GetAttributes()[0].GetLocation();
+
+ var expectedDiagnostics = new DiagnosticData[]
+ {
+ new(DiagnosticSeverity.Warning, jsonSerializableAttrLocation, "Did not generate serialization metadata for type 'HelloWorld.TypeWithArityMismatch'."),
+ new(DiagnosticSeverity.Warning, converterAttrLocation, "The 'JsonConverterAttribute' type 'HelloWorld.ConverterWithTwoParams<,>' specified on member 'HelloWorld.TypeWithArityMismatch' is not a converter type or does not contain an accessible parameterless constructor."),
+ };
+
+ CompilationHelper.AssertEqualDiagnosticMessages(expectedDiagnostics, result.Diagnostics);
+ }
+
+ [Fact]
+ public void GenericConverterTypeMismatch_NoSourceGeneratorWarning_FailsAtRuntime()
+ {
+ // Note: The source generator cannot detect at compile time that the converter
+ // converts the wrong type (DifferentType vs TypeWithConverterMismatch).
+ // The DifferentTypeConverter is a valid JsonConverter with a parameterless constructor,
+ // so the source generator accepts it. The mismatch will cause a runtime error.
+ Compilation compilation = CompilationHelper.CreateTypesWithGenericConverterTypeMismatch();
+ JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation);
+
+ // Should compile without diagnostics - the converter is technically valid
+ Assert.Empty(result.Diagnostics);
+ result.AssertContainsType("global::HelloWorld.TypeWithConverterMismatch");
+ }
+
+ [Fact]
+ public void NestedGenericConverter_CompileSuccessfully()
+ {
+ Compilation compilation = CompilationHelper.CreateTypesWithNestedGenericConverter();
+ JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation);
+
+ // Should compile without diagnostics
+ Assert.Empty(result.Diagnostics);
+ result.AssertContainsType("global::HelloWorld.TypeWithNestedConverter");
+ }
+
+ [Fact]
+ public void ConstrainedGenericConverter_WithSatisfiedConstraint_CompileSuccessfully()
+ {
+ Compilation compilation = CompilationHelper.CreateTypesWithConstrainedGenericConverter();
+ JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation);
+
+ Assert.Empty(result.Diagnostics);
+ result.AssertContainsType("global::HelloWorld.TypeWithSatisfiedConstraint");
+ }
+
+ [Fact]
+ public void DeeplyNestedGenericConverter_CompileSuccessfully()
+ {
+ Compilation compilation = CompilationHelper.CreateTypesWithDeeplyNestedGenericConverter();
+ JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation);
+
+ Assert.Empty(result.Diagnostics);
+ result.AssertContainsType("global::HelloWorld.TypeWithDeeplyNestedConverter");
+ }
+
+ [Fact]
+ public void NonGenericOuterGenericConverter_CompileSuccessfully()
+ {
+ Compilation compilation = CompilationHelper.CreateTypesWithNonGenericOuterGenericConverter();
+ JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation);
+
+ Assert.Empty(result.Diagnostics);
+ result.AssertContainsType("global::HelloWorld.TypeWithNonGenericOuterConverter");
+ }
+
+ [Fact]
+ public void ManyParamsAsymmetricNestedConverter_CompileSuccessfully()
+ {
+ Compilation compilation = CompilationHelper.CreateTypesWithManyParamsAsymmetricNestedConverter();
+ JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation);
+
+ Assert.Empty(result.Diagnostics);
+ result.AssertContainsType("global::HelloWorld.TypeWithManyParams");
+ }
}
}
diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.Attribute.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.Attribute.cs
index 9977b65875462e..7752b464b5bda4 100644
--- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.Attribute.cs
+++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.Attribute.cs
@@ -223,5 +223,730 @@ public static void CustomAttributeOnTypeAndRuntime()
string jsonSerialized = JsonSerializer.Serialize(point, options);
Assert.Equal(json, jsonSerialized);
}
+
+ // Tests for open generic converters on generic types
+
+ ///
+ /// A generic option type that represents an optional value.
+ /// The converter type is an open generic, which will be constructed
+ /// to match the type arguments of the Option type.
+ ///
+ [JsonConverter(typeof(OptionConverter<>))]
+ public readonly struct Option
+ {
+ public bool HasValue { get; }
+ public T Value { get; }
+
+ public Option(T value)
+ {
+ HasValue = true;
+ Value = value;
+ }
+
+ public static implicit operator Option(T value) => new(value);
+ }
+
+ ///
+ /// Generic converter for the Option type. Serializes the value if present,
+ /// or null if not.
+ ///
+ public sealed class OptionConverter : JsonConverter>
+ {
+ public override bool HandleNull => true;
+
+ public override Option Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ return default;
+ }
+
+ return new(JsonSerializer.Deserialize(ref reader, options)!);
+ }
+
+ public override void Write(Utf8JsonWriter writer, Option value, JsonSerializerOptions options)
+ {
+ if (!value.HasValue)
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ JsonSerializer.Serialize(writer, value.Value, options);
+ }
+ }
+
+ [Fact]
+ public static void GenericConverterAttributeOnGenericType_Serialize()
+ {
+ // Test serialization with a value
+ Option option = new Option(42);
+ string json = JsonSerializer.Serialize(option);
+ Assert.Equal("42", json);
+
+ // Test serialization without a value
+ Option emptyOption = default;
+ json = JsonSerializer.Serialize(emptyOption);
+ Assert.Equal("null", json);
+ }
+
+ [Fact]
+ public static void GenericConverterAttributeOnGenericType_Deserialize()
+ {
+ // Test deserialization with a value
+ Option option = JsonSerializer.Deserialize>("42");
+ Assert.True(option.HasValue);
+ Assert.Equal(42, option.Value);
+
+ // Test deserialization of null
+ option = JsonSerializer.Deserialize >("null");
+ Assert.False(option.HasValue);
+ }
+
+ [Fact]
+ public static void GenericConverterAttributeOnGenericType_ComplexType()
+ {
+ // Test with a complex type
+ Option option = new Option("hello");
+ string json = JsonSerializer.Serialize(option);
+ Assert.Equal(@"""hello""", json);
+
+ option = JsonSerializer.Deserialize>(json);
+ Assert.True(option.HasValue);
+ Assert.Equal("hello", option.Value);
+ }
+
+ [Fact]
+ public static void GenericConverterAttributeOnGenericType_NestedInClass()
+ {
+ // Test Option type when used as a property
+ var obj = new ClassWithOptionProperty { Name = "Test", OptionalValue = 42 };
+ string json = JsonSerializer.Serialize(obj);
+ Assert.Equal(@"{""Name"":""Test"",""OptionalValue"":42}", json);
+
+ var deserialized = JsonSerializer.Deserialize(json);
+ Assert.Equal("Test", deserialized.Name);
+ Assert.True(deserialized.OptionalValue.HasValue);
+ Assert.Equal(42, deserialized.OptionalValue.Value);
+ }
+
+ private class ClassWithOptionProperty
+ {
+ public string Name { get; set; }
+ public Option OptionalValue { get; set; }
+ }
+
+ ///
+ /// A generic result type that represents either a success value or an error.
+ /// Tests a generic converter with two type parameters.
+ ///
+ [JsonConverter(typeof(ResultConverter<,>))]
+ public readonly struct Result
+ {
+ public bool IsSuccess { get; }
+ public TValue Value { get; }
+ public TError Error { get; }
+
+ private Result(TValue value, TError error, bool isSuccess)
+ {
+ Value = value;
+ Error = error;
+ IsSuccess = isSuccess;
+ }
+
+ public static Result Success(TValue value) =>
+ new(value, default!, true);
+
+ public static Result Failure(TError error) =>
+ new(default!, error, false);
+ }
+
+ ///
+ /// Generic converter for the Result type with two type parameters.
+ ///
+ public sealed class ResultConverter : JsonConverter>
+ {
+ public override Result Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ {
+ throw new JsonException();
+ }
+
+ bool? isSuccess = null;
+ TValue value = default!;
+ TError error = default!;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ {
+ break;
+ }
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ throw new JsonException();
+ }
+
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ switch (propertyName)
+ {
+ case "IsSuccess":
+ isSuccess = reader.GetBoolean();
+ break;
+ case "Value":
+ value = JsonSerializer.Deserialize(ref reader, options)!;
+ break;
+ case "Error":
+ error = JsonSerializer.Deserialize(ref reader, options)!;
+ break;
+ }
+ }
+
+ if (isSuccess == true)
+ {
+ return Result.Success(value);
+ }
+
+ return Result.Failure(error);
+ }
+
+ public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WriteBoolean("IsSuccess", value.IsSuccess);
+
+ if (value.IsSuccess)
+ {
+ writer.WritePropertyName("Value");
+ JsonSerializer.Serialize(writer, value.Value, options);
+ }
+ else
+ {
+ writer.WritePropertyName("Error");
+ JsonSerializer.Serialize(writer, value.Error, options);
+ }
+
+ writer.WriteEndObject();
+ }
+ }
+
+ [Fact]
+ public static void GenericConverterAttributeOnGenericType_TwoTypeParameters_Success()
+ {
+ var result = Result.Success(42);
+ string json = JsonSerializer.Serialize(result);
+ Assert.Equal(@"{""IsSuccess"":true,""Value"":42}", json);
+
+ var deserialized = JsonSerializer.Deserialize>(json);
+ Assert.True(deserialized.IsSuccess);
+ Assert.Equal(42, deserialized.Value);
+ }
+
+ [Fact]
+ public static void GenericConverterAttributeOnGenericType_TwoTypeParameters_Failure()
+ {
+ var result = Result.Failure("error message");
+ string json = JsonSerializer.Serialize(result);
+ Assert.Equal(@"{""IsSuccess"":false,""Error"":""error message""}", json);
+
+ var deserialized = JsonSerializer.Deserialize>(json);
+ Assert.False(deserialized.IsSuccess);
+ Assert.Equal("error message", deserialized.Error);
+ }
+
+ ///
+ /// Test that an open generic converter can be used on a property with [JsonConverter].
+ ///
+ [Fact]
+ public static void GenericConverterAttributeOnProperty()
+ {
+ var obj = new ClassWithGenericConverterOnProperty { Value = new MyGenericWrapper(42) };
+ string json = JsonSerializer.Serialize(obj);
+ Assert.Equal(@"{""Value"":42}", json);
+
+ var deserialized = JsonSerializer.Deserialize(json);
+ Assert.Equal(42, deserialized.Value.WrappedValue);
+ }
+
+ private class ClassWithGenericConverterOnProperty
+ {
+ [JsonConverter(typeof(MyGenericWrapperConverter<>))]
+ public MyGenericWrapper Value { get; set; }
+ }
+
+ public class MyGenericWrapper
+ {
+ public T WrappedValue { get; }
+
+ public MyGenericWrapper(T value)
+ {
+ WrappedValue = value;
+ }
+ }
+
+ public sealed class MyGenericWrapperConverter : JsonConverter>
+ {
+ public override MyGenericWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ T value = JsonSerializer.Deserialize(ref reader, options)!;
+ return new MyGenericWrapper(value);
+ }
+
+ public override void Write(Utf8JsonWriter writer, MyGenericWrapper value, JsonSerializerOptions options)
+ {
+ JsonSerializer.Serialize(writer, value.WrappedValue, options);
+ }
+ }
+
+ // Tests for type parameter arity mismatch
+ [Fact]
+ public static void GenericConverterAttribute_ArityMismatch_ThrowsInvalidOperationException()
+ {
+ // The converter has two type parameters but the type has one.
+ // This throws InvalidOperationException with a contextual error message
+ // because the arity doesn't match.
+ Assert.Throws(() => JsonSerializer.Serialize(new TypeWithArityMismatch()));
+ }
+
+ [JsonConverter(typeof(ConverterWithTwoParams<,>))]
+ public class TypeWithArityMismatch
+ {
+ public T Value { get; set; }
+ }
+
+ public sealed class ConverterWithTwoParams : JsonConverter>
+ {
+ public override TypeWithArityMismatch Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+
+ public override void Write(Utf8JsonWriter writer, TypeWithArityMismatch value, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+ }
+
+ // Tests for type constraint violations
+ [Fact]
+ public static void GenericConverterAttribute_ConstraintViolation_ThrowsArgumentException()
+ {
+ // The converter has a class constraint but int is a value type
+ Assert.Throws(() => JsonSerializer.Serialize(new TypeWithConstraintViolation()));
+ }
+
+ [JsonConverter(typeof(ConverterWithClassConstraint<>))]
+ public class TypeWithConstraintViolation
+ {
+ public T Value { get; set; }
+ }
+
+ public sealed class ConverterWithClassConstraint : JsonConverter> where T : class
+ {
+ public override TypeWithConstraintViolation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+
+ public override void Write(Utf8JsonWriter writer, TypeWithConstraintViolation value, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+ }
+
+ // Tests for converter type that doesn't match the target type
+ [Fact]
+ public static void GenericConverterAttribute_ConverterTypeMismatch_ThrowsInvalidOperationException()
+ {
+ // The converter converts DifferentType but the type is TypeWithConverterMismatch
+ Assert.Throws(() => JsonSerializer.Serialize(new TypeWithConverterMismatch()));
+ }
+
+ [JsonConverter(typeof(DifferentTypeConverter<>))]
+ public class TypeWithConverterMismatch
+ {
+ public T Value { get; set; }
+ }
+
+ public class DifferentType
+ {
+ public T Value { get; set; }
+ }
+
+ public sealed class DifferentTypeConverter : JsonConverter>
+ {
+ public override DifferentType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+
+ public override void Write(Utf8JsonWriter writer, DifferentType value, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+ }
+
+ // Tests for open generic converter on a non-generic type
+ [Fact]
+ public static void GenericConverterAttribute_OpenGenericOnNonGenericType_ThrowsInvalidOperationException()
+ {
+ // The converter is an open generic but the target type is not generic,
+ // so the converter type cannot be instantiated. We throw InvalidOperationException
+ // with a contextual error message.
+ Assert.Throws(() => JsonSerializer.Serialize(new NonGenericTypeWithOpenGenericConverter()));
+ }
+
+ [JsonConverter(typeof(OpenGenericConverterForNonGenericType<>))]
+ public class NonGenericTypeWithOpenGenericConverter
+ {
+ public string Name { get; set; }
+ }
+
+ public sealed class OpenGenericConverterForNonGenericType : JsonConverter
+ {
+ public override NonGenericTypeWithOpenGenericConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+
+ public override void Write(Utf8JsonWriter writer, NonGenericTypeWithOpenGenericConverter value, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+ }
+
+ // Tests for swapped parameter order in converter
+ [Fact]
+ public static void GenericConverterAttribute_SwappedParameterOrder_ThrowsInvalidOperationException()
+ {
+ // The converter converts TypeWithSwappedParams but the type is TypeWithSwappedParams,
+ // so MakeGenericType succeeds but CanConvert check fails.
+ Assert.Throws(() => JsonSerializer.Serialize(new TypeWithSwappedParams()));
+ }
+
+ [JsonConverter(typeof(SwappedParamsConverter<,>))]
+ public class TypeWithSwappedParams
+ {
+ public T1 First { get; set; }
+ public T2 Second { get; set; }
+ }
+
+ public sealed class SwappedParamsConverter : JsonConverter>
+ {
+ public override TypeWithSwappedParams Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+
+ public override void Write(Utf8JsonWriter writer, TypeWithSwappedParams value, JsonSerializerOptions options)
+ => throw new NotImplementedException();
+ }
+
+ // Tests for nested containing class with type parameters
+ [Fact]
+ public static void GenericConverterAttribute_NestedConverter_Works()
+ {
+ // Converter is nested in a generic container class
+ var value = new TypeWithNestedConverter { Value1 = 42, Value2 = "hello" };
+ string json = JsonSerializer.Serialize(value);
+ Assert.Equal(@"{""Value1"":42,""Value2"":""hello""}", json);
+
+ var deserialized = JsonSerializer.Deserialize>(json);
+ Assert.Equal(42, deserialized.Value1);
+ Assert.Equal("hello", deserialized.Value2);
+ }
+
+ [JsonConverter(typeof(Container<>.NestedConverter<>))]
+ public class TypeWithNestedConverter
+ {
+ public T1 Value1 { get; set; }
+ public T2 Value2 { get; set; }
+ }
+
+ public class Container
+ {
+ public sealed class NestedConverter : JsonConverter>
+ {
+ public override TypeWithNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException();
+
+ var result = new TypeWithNestedConverter();
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ throw new JsonException();
+
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ if (propertyName == "Value1")
+ result.Value1 = JsonSerializer.Deserialize(ref reader, options)!;
+ else if (propertyName == "Value2")
+ result.Value2 = JsonSerializer.Deserialize(ref reader, options)!;
+ }
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, TypeWithNestedConverter value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("Value1");
+ JsonSerializer.Serialize(writer, value.Value1, options);
+ writer.WritePropertyName("Value2");
+ JsonSerializer.Serialize(writer, value.Value2, options);
+ writer.WriteEndObject();
+ }
+ }
+ }
+
+ // Tests for type parameters with constraints that are satisfied
+ [Fact]
+ public static void GenericConverterAttribute_ConstraintSatisfied_Works()
+ {
+ var value = new TypeWithSatisfiedConstraint { Value = "test" };
+ string json = JsonSerializer.Serialize(value);
+ Assert.Equal(@"{""Value"":""test""}", json);
+
+ var deserialized = JsonSerializer.Deserialize>(json);
+ Assert.Equal("test", deserialized.Value);
+ }
+
+ [JsonConverter(typeof(ConverterWithSatisfiedConstraint<>))]
+ public class TypeWithSatisfiedConstraint
+ {
+ public T Value { get; set; }
+ }
+
+ public sealed class ConverterWithSatisfiedConstraint : JsonConverter> where T : class
+ {
+ public override TypeWithSatisfiedConstraint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException();
+
+ var result = new TypeWithSatisfiedConstraint();
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ throw new JsonException();
+
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ if (propertyName == "Value")
+ result.Value = JsonSerializer.Deserialize(ref reader, options)!;
+ }
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, TypeWithSatisfiedConstraint value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("Value");
+ JsonSerializer.Serialize(writer, value.Value, options);
+ writer.WriteEndObject();
+ }
+ }
+
+ // Tests for generic within non-generic within generic: Outer<>.Middle.Inner<>
+ [Fact]
+ public static void GenericConverterAttribute_DeeplyNestedConverter_Works()
+ {
+ var value = new TypeWithDeeplyNestedConverter { Value1 = 99, Value2 = "deep" };
+ string json = JsonSerializer.Serialize(value);
+ Assert.Equal(@"{""Value1"":99,""Value2"":""deep""}", json);
+
+ var deserialized = JsonSerializer.Deserialize>(json);
+ Assert.Equal(99, deserialized.Value1);
+ Assert.Equal("deep", deserialized.Value2);
+ }
+
+ [JsonConverter(typeof(OuterGeneric<>.MiddleNonGeneric.InnerConverter<>))]
+ public class TypeWithDeeplyNestedConverter