Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1e3e556
Implement support for generic converters on generic types
Copilot Jan 15, 2026
2e1b35f
Fix source generator to support open generic converter types by remov…
Copilot Jan 15, 2026
cf0f559
Fix source generator code to properly construct closed generic conver…
Copilot Jan 15, 2026
d6b5fdd
Add tests for complex generic converter scenarios and fix nested gene…
Copilot Jan 15, 2026
648d32b
Fix test assertions for exception types and add required types to sou…
Copilot Jan 15, 2026
e38ee8b
Fix diagnostic test to expect both warnings for arity mismatch
Copilot Jan 15, 2026
9b1bba8
Fix code review issues: use nullable return type and add null-forgivi…
Copilot Jan 15, 2026
3ee8f69
Fix ConstructNestedGenericType to use explicit arity and handle null …
Copilot Jan 26, 2026
5c5afd0
Merge branch 'main' into copilot/support-generic-converters
stephentoub Feb 25, 2026
837d090
Combine source gen tests with [Theory] and add deeply nested generic …
Copilot Feb 25, 2026
270b4e1
Add test with 5 type params asymmetrically distributed across nesting…
Copilot Feb 25, 2026
9b99f8b
Merge main into copilot/support-generic-converters
Copilot Mar 18, 2026
a62cce4
Merge branch 'main' into copilot/support-generic-converters
eiriktsarpalis Mar 18, 2026
3e2fbae
Add negative tests for open generic converter on non-generic type and…
Copilot Mar 18, 2026
5cffdfe
Throw InvalidOperationException with contextual message for open gene…
Copilot Mar 19, 2026
7104498
Merge branch 'main' into copilot/support-generic-converters
eiriktsarpalis Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 123 additions & 7 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1217,6 +1217,7 @@ private bool IsValidDataExtensionPropertyType(ITypeSymbol type)
ProcessMemberCustomAttributes(
contextType,
memberInfo,
memberType,
out bool hasJsonInclude,
out string? jsonPropertyName,
out JsonIgnoreCondition? ignoreCondition,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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));

Expand All @@ -1669,25 +1671,139 @@ 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)))
{
ReportDiagnostic(DiagnosticDescriptors.JsonConverterAttributeInvalidType, attributeData.GetLocation(), converterType?.ToDisplayString() ?? "null", declaringSymbol.ToDisplayString());
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);
}

/// <summary>
/// Gets the total number of type parameters from an unbound generic type,
/// including type parameters from containing types for nested generics.
/// For example, Container&lt;&gt;.NestedConverter&lt;&gt; has a total of 2 type parameters.
/// </summary>
private static int GetTotalTypeParameterCount(INamedTypeSymbol unboundType)
{
int count = 0;
INamedTypeSymbol? current = unboundType;
while (current != null)
{
count += current.TypeParameters.Length;
current = current.ContainingType;
}
return count;
}

/// <summary>
/// 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.
/// </summary>
private static INamedTypeSymbol? ConstructNestedGenericType(INamedTypeSymbol unboundType, ImmutableArray<ITypeSymbol> typeArguments)
{
// Build the chain of containing types from outermost to innermost
var typeChain = new List<INamedTypeSymbol>();
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)
Expand Down
3 changes: 3 additions & 0 deletions src/libraries/System.Text.Json/src/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,9 @@
<data name="SerializationConverterOnAttributeNotCompatible" xml:space="preserve">
<value>The converter specified on '{0}' is not compatible with the type '{1}'.</value>
</data>
<data name="SerializationConverterOnAttributeOpenGenericNotCompatible" xml:space="preserve">
<value>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.</value>
</data>
<data name="SerializationConverterOnAttributeInvalid" xml:space="preserve">
<value>The converter specified on '{0}' does not derive from JsonConverter or have a public parameterless constructor.</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,22 @@ private static JsonConverter GetConverterFromAttribute(JsonConverterAttribute co
}
else
{
// Handle open generic converter types (e.g., OptionConverter<> on Option<T>).
// 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Loading
Loading