Skip to content

Support generic converters on generic types with JsonConverterAttribute#123209

Merged
eiriktsarpalis merged 16 commits intomainfrom
copilot/support-generic-converters
Mar 19, 2026
Merged

Support generic converters on generic types with JsonConverterAttribute#123209
eiriktsarpalis merged 16 commits intomainfrom
copilot/support-generic-converters

Conversation

Copy link
Contributor

Copilot AI commented Jan 15, 2026

Description

JsonConverterAttribute now supports open generic converter types on generic types when the type parameter arity matches. This works for both the reflection-based provider and source generator.

Example Usage

[JsonConverter(typeof(OptionConverter<>))]
public readonly struct Option<T>
{
    public bool HasValue { get; }
    public T Value { get; }

    public Option(T value)
    {
        HasValue = true;
        Value = value;
    }
}

public sealed class OptionConverter<T> : JsonConverter<Option<T>>
{
    // ... implementation
}

// Now works - previously threw "Type.ContainsGenericParameters is true"
JsonSerializer.Serialize(new Option<int>(42));

Changes

  • DefaultJsonTypeInfoResolver.Converters.cs: When converter type is an open generic with matching arity to typeToConvert, automatically construct the closed generic converter type using MakeGenericType. The reflection APIs (Type.GetGenericArguments() and Type.MakeGenericType()) handle nested generic types implicitly. When an open generic converter is used on an incompatible type (e.g., non-generic type or arity mismatch), an InvalidOperationException with a contextual error message is thrown proactively rather than surfacing internal ArgumentException from Activator.CreateInstance.

  • ThrowHelper.Serialization.cs: Added ThrowInvalidOperationException_IncompatibleOpenGenericConverterType helper for the new contextual error message.

  • Strings.resx: Added IncompatibleOpenGenericConverterType resource string: "The open generic converter type '{0}' is not compatible with type '{1}'. Ensure that the total number of generic type parameters on the converter matches the number on the target type."

  • JsonSourceGenerator.Parser.cs:

    • Added GetTotalTypeParameterCount() to count all type parameters including those from containing types for nested generics
    • Added ConstructNestedGenericType() to properly construct closed generic types for nested generic converters (e.g., Container<>.NestedConverter<>)
    • Propagate typeToConvert through converter attribute resolution to enable open generic detection
    • The source generator requires manual traversal of the nesting hierarchy because Roslyn's INamedTypeSymbol only exposes TypeParameters for the immediate type, unlike reflection's Type.GetGenericArguments() which returns all type parameters across the hierarchy
  • Tests: Comprehensive coverage across reflection, source generator integration, and source generator unit tests including:

    • Basic single type parameter (Option<T> with OptionConverter<T>)
    • Multiple type parameters (Result<TValue, TError> with ResultConverter<TValue, TError>)
    • Property-level open generic converters
    • Nested generic converters (Container<>.NestedConverter<>)
    • Deeply nested converters with non-generic intermediate types (OuterGeneric<>.MiddleNonGeneric.InnerConverter<>)
    • Converters nested in non-generic outer classes (NonGenericOuter.SingleLevelConverter<>)
    • Asymmetric nesting with many type parameters distributed unevenly across levels (Level1<,,>.Level2<>.Level3Converter<> with 5 params split 3+1+1)
    • Constrained generic converters (where T : class)
    • Error scenarios: arity mismatch, constraint violations, converter type mismatch, open generic converter on non-generic type, swapped parameter order
    • Source generator diagnostic tests for arity mismatch warnings
    • Tests use [Theory] with [InlineData] where applicable per codebase conventions

Supported Scenarios

  • Simple generic converters: [JsonConverter(typeof(OptionConverter<>))] on Option<T>
  • Multiple type parameters: [JsonConverter(typeof(ResultConverter<,>))] on Result<TValue, TError>
  • Nested generic converters: [JsonConverter(typeof(Container<>.NestedConverter<>))] on TypeWithNestedConverter<T1, T2> — type parameters matched in order of definition regardless of nestedness
  • Deeply nested converters: [JsonConverter(typeof(OuterGeneric<>.MiddleNonGeneric.InnerConverter<>))] — generic within non-generic within generic
  • Asymmetric nesting: [JsonConverter(typeof(Level1<,,>.Level2<>.Level3Converter<>))] — each nesting level consumes a different number of type parameters (3+1+1)
  • Non-generic outer converters: [JsonConverter(typeof(NonGenericOuter.SingleLevelConverter<>))]
  • Constrained generic converters: ConverterWithClassConstraint<T> where T : class

Error Handling

  • Open generic converter on non-generic type: Throws InvalidOperationException with a contextual error message explaining the incompatibility between the open generic converter and the target type
  • Arity mismatch: Throws InvalidOperationException with contextual error message at runtime; source generator emits warning diagnostic
  • Constraint violation: Throws ArgumentException at runtime (from MakeGenericType)
  • Converter type mismatch: Throws InvalidOperationException at runtime (converter doesn't convert the target type)
  • Swapped parameter order: Throws InvalidOperationException at runtime because the constructed converter (e.g., SwappedConverter<int, string> converting MyType<string, int>) doesn't match the expected converter type for the target
Original prompt

This section details on the original issue you should resolve

<issue_title>JsonConverterAttribute should support generic converters on generic types</issue_title>
<issue_description>The following code:

using System.Text.Json;
using System.Text.Json.Serialization;

JsonSerializer.Serialize(new Option<int>(42));

[JsonConverter(typeof(OptionConverter<>))]
public readonly struct Option<T>
{
    public bool HasValue { get; }
    public T Value { get; }

    public Option(T value)
    {
        HasValue = true;
        Value = value;
    }

    public static implicit operator Option<T>(T value) => new(value);
}

public sealed class OptionConverter<T> : JsonConverter<Option<T>>
{
    public override bool HandleNull => true;

    public override Option<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        return new(JsonSerializer.Deserialize<T>(ref reader, options)!);
    }

    public override void Write(Utf8JsonWriter writer, Option<T> value, JsonSerializerOptions options)
    {
        if (value.HasValue)
        {
            writer.WriteNullValue();
            return;
        }

        JsonSerializer.Serialize(writer, value.Value, options);
    }
}

fails today with the error:

Unhandled exception. System.ArgumentException: Cannot create an instance of OptionConverter`1[T] because Type.ContainsGenericParameters is true.

We should make such use cases work provided that type parameters of the converter precisely match the arity of the targeted type. Such a convention would also work well in the context of the source generator and would provide a good solution for the problem as posited in #73124</issue_description>

<agent_instructions>See if you can make this work for both the reflection based provider and source generator. As a reference you can use https://github.com/eiriktsarpalis/PolyType which already supports this pattern for marshallers in surrogate types.</agent_instructions>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI changed the title [WIP] Add support for generic converters on generic types Support generic converters on generic types with JsonConverterAttribute Jan 15, 2026
Copilot AI requested a review from eiriktsarpalis January 15, 2026 10:47
@eiriktsarpalis
Copy link
Member

@copilot there appear to be build failures related to the source generator. Please fix those.

Copy link
Contributor Author

Copilot AI commented Jan 15, 2026

@copilot there appear to be build failures related to the source generator. Please fix those.

Fixed in e5944c5. The issue was that the code tried to assign to namedConverterType from a pattern-matched variable that wasn't in scope when entering the if block via certain conditions. I restructured the code to first check for unbound generic types and construct them, then validate the resulting type.

Copy link
Member

@eiriktsarpalis eiriktsarpalis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot you should add a few tests that check what happens if there is a mismatch in type parameters or type constraints between the two types, or if a the generic instantiation implements a converter not matching the original type. Add tests both at runtime and source generator unit tests checking for relevant diagnostic messages.

Copy link
Contributor Author

Copilot AI commented Jan 15, 2026

@copilot you should add a few tests that check what happens if there is a mismatch in type parameters or type constraints between the two types, or if a the generic instantiation implements a converte...

Added tests in 92d1a10:

  • Arity mismatch: GenericConverterAttribute_ArityMismatch_ThrowsArgumentException verifies that converters with mismatched type parameter counts throw ArgumentException
  • Constraint violation: GenericConverterAttribute_ConstraintViolation_ThrowsArgumentException verifies that constraint violations throw ArgumentException
  • Type mismatch: GenericConverterAttribute_ConverterTypeMismatch_ThrowsInvalidOperationException verifies that converters for different types throw InvalidOperationException
  • Source generator unit tests: GenericConverterArityMismatch_WarnsAsExpected and GenericConverterTypeMismatch_NoSourceGeneratorWarning_FailsAtRuntime (source generator cannot detect type mismatch at compile time)

@eiriktsarpalis
Copy link
Member

eiriktsarpalis commented Jan 26, 2026

@stephentoub @PranavSenthilnathan before we get this merged, I would appreciate feedback on the merits of this new convention.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends JsonConverterAttribute to accept open generic converter types when applied to generic target types with matching arity, supporting both the reflection-based resolver and the source generator (including nested generic converter scenarios).

Changes:

  • Runtime: construct a closed converter type from an open generic converter type when the target type is a closed generic with matching arity.
  • Source generator: resolve JsonConverterAttribute for open generic (including nested) converter types by constructing the appropriate closed converter type.
  • Tests: add reflection and source-generation test coverage for supported scenarios and key failure modes (e.g., arity mismatch diagnostics).

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Converters.cs Constructs closed generic converter types at runtime when attribute specifies an open generic converter with matching arity.
src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs Adds logic to close unbound generic (including nested) converter types during generator parsing.
src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.Attribute.cs Adds reflection-based tests covering open generic converters, nested converters, constraints, and mismatch scenarios.
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs Adds generator diagnostic tests for arity mismatch and verifies generator behavior for other scenarios.
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/CompilationHelper.cs Adds compilation helpers to generate test sources for the new diagnostic and nested/constrained scenarios.
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/TestClasses.CustomConverters.cs Adds source-gen test model types/converters for open generic and nested generic converter cases.
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs Adds integration tests validating source-gen contexts can serialize/deserialize with open generic converters.

@stephentoub
Copy link
Member

@stephentoub @pranavkm before we get this merged, I would appreciate feedback on the merits of this new convention.

@eiriktsarpalis what's the downside?

@eiriktsarpalis
Copy link
Member

@stephentoub @pranavkm before we get this merged, I would appreciate feedback on the merits of this new convention.

@eiriktsarpalis what's the downside?

None that I'm aware of. With the exception of debugger proxies I'm not aware of any serializers/source generators applying this convention, so it might be difficult for users to discover.

@stephentoub
Copy link
Member

It seems fine to me. Hard to imagine what else it could mean.

@eiriktsarpalis
Copy link
Member

@copilot please file a PR against https://github.com/dotnet/docs extending the https://github.com/dotnet/docs/blob/main/docs/standard/serialization/system-text-json/converters-how-to.md article with instructions and examples of how to use this new convention. Explain how in certain cases it provides an AOT compatible alternative JsonConverterFactory.

@eiriktsarpalis
Copy link
Member

@copilot Please rebase the changes and fix any merge conflicts.

# Conflicts:
#	src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs
Copy link
Contributor Author

Copilot AI commented Mar 18, 2026

@copilot Please rebase the changes and fix any merge conflicts.

Merged main into the PR branch and resolved the conflict in JsonSourceGeneratorDiagnosticsTests.cs (kept both the new Diagnostic_HasPragmaSuppressibleLocation test from main and the generic converter tests) in 9b99f8b.

@eiriktsarpalis eiriktsarpalis enabled auto-merge (squash) March 18, 2026 16:21
Copy link
Member

@tarekgh tarekgh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Would it make sense to add a negative test to the following cases?

   // Open generic converter on a non-generic type

   [JsonConverter(typeof(MyConverter<>))]   // open generic converter
   public class PlainClass                   // NOT generic - no <T>
   {
       public string Name { get; set; }
   }
  // Swapped parameter order

   [JsonConverter(typeof(SwappedConverter<,>))]
   public class MyType<T1, T2>
   {
       public T1 First { get; set; }
       public T2 Second { get; set; }
   }
   
   public class SwappedConverter<A, B> : JsonConverter<MyType<B, A>>  // note: B,A not A,B
   {
       // ...
   }

@eiriktsarpalis
Copy link
Member

@copilot please act on @tarekgh's feedback. In the first case we should fail with a relevant error message because the converter cannot be instantiated. In the second case it should fail because the converter instance doesn't subclass the right converter type.

… swapped parameter order

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
auto-merge was automatically disabled March 18, 2026 17:40

Head branch was pushed to by a user without write access

Copy link
Contributor Author

Copilot AI commented Mar 18, 2026

@copilot please act on @tarekgh's feedback. In the first case we should fail with a relevant error message because the converter cannot be instantiated. In the second case it should fa...

Added both tests in 3e2fbae:

  1. Open generic converter on non-generic type (GenericConverterAttribute_OpenGenericOnNonGenericType_ThrowsArgumentException): The arity check is skipped since typeToConvert.IsGenericType is false, so the converter type remains an unbound generic. Activator.CreateInstance then throws ArgumentException with "Cannot create an instance of ... because Type.ContainsGenericParameters is true."

  2. Swapped parameter order (GenericConverterAttribute_SwappedParameterOrder_ThrowsInvalidOperationException): MakeGenericType succeeds (arity matches), but the resulting converter converts TypeWithSwappedParams<string, int> instead of TypeWithSwappedParams<int, string>, so the CanConvert check fails and throws InvalidOperationException.

@eiriktsarpalis
Copy link
Member

@copilot we should not be surfacing the ArgumentException from Activator.CreateInstance since that is not good form. We should anticipate the error mode and instead throw an InvalidOperationException providing a contextual error message.

…ric converter on incompatible type

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 19, 2026

@copilot we should not be surfacing the ArgumentException from Activator.CreateInstance since that is not good form. We should anticipate the error mode and instead throw an `Invalid...

Done in 5cffdfe. The open generic converter on non-generic type and arity mismatch cases now both throw InvalidOperationException with a contextual message: "The open generic converter type '{converterType}' specified on '{location}' cannot be instantiated because the target type is not a generic type with a matching number of type parameters." The early detection in the IsGenericTypeDefinition check prevents the ArgumentException from Activator.CreateInstance from ever being surfaced.

@eiriktsarpalis
Copy link
Member

/ba-g test failures unrelated

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JsonConverterAttribute should support generic converters on generic types

5 participants