Skip to content

NullabilityInfoContext returns incorrect NullabilityInfo values for Generics #115014

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
ShawnOwczarzak opened this issue Apr 24, 2025 · 3 comments
Labels
area-System.Reflection question Answer questions and provide assistance, not an issue with source code or documentation.

Comments

@ShawnOwczarzak
Copy link

Description

When using the NullabilityInfoContext to fetch NullabilityInfo for a generic property, the ReadState and WriteState are always Nullable, even when the defined generic has the type as NotNull.

Reproduction Steps

using System.Reflection;

#nullable enable
namespace NullabilityInfoWithGenerics
{
    internal class Program
    {
        static void Main(string[] args)
        {
            PropertyInfo valueProperty = typeof(MyGenericClass<string>).GetProperty("Value")!;
            NullabilityInfo NullabilityInfo = new NullabilityInfoContext().Create(valueProperty);

            // Expect NotNull
            Console.WriteLine(NullabilityInfo.ReadState);
        }
    }
    public record MyGenericClass<T>(T Value);   
}
#nullable restore

Expected behavior

The resulting NullabilityInfo's ReadState and WriteState should match the implemented generic type. string == NotNull. string? == Nullable

Actual behavior

When genaric T is a reference type, NullablityInfo has the ReadState Nullable.

Regression?

No response

Known Workarounds

Not using generics works fine (see below). But it's not really a work around.

Configuration

Dotnet 8.0
Nullable enabled

Other information

More Expected/Actual

Type                             | Expected   | Actual
-------------------------------------------------------
MyGenericClass`1<String>         | NotNull    | Nullable
MyGenericClass`1<String>         | Nullable   | Nullable
MyGenericClass`1<Int32>          | NotNull    | NotNull
MyGenericClass`1<Int32?>         | Nullable   | Nullable
MyGenericClass`1<TestClass>      | NotNull    | Nullable
MyGenericClass`1<TestClass?>     | Nullable   | Nullable
MyStringClass                    | NotNull    | NotNull
MyNullableStringClass            | Nullable   | Nullable
MyIntClass                       | NotNull    | NotNull
MyNullableIntClass               | Nullable   | Nullable
MyTestClassClass                 | NotNull    | NotNull
MyNullableTestClassClass         | Nullable   | Nullable
using System.Reflection;

#nullable enable
namespace NullabilityInfoWithGenerics
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("{0, -30} | {1, -10} | {2} ", "Type", "Expected", "Actual");
            Console.WriteLine("-------------------------------------------------------");

            CheckNullability<MyGenericClass<string>>(NullabilityState.NotNull);  // Mismatch
            CheckNullability<MyGenericClass<string?>>(NullabilityState.Nullable);
            
            CheckNullability<MyGenericClass<int>>(NullabilityState.NotNull);
            CheckNullability<MyGenericClass<int?>>(NullabilityState.Nullable);
            
            CheckNullability<MyGenericClass<TestClass?>>(NullabilityState.NotNull);  // Mismatch
            CheckNullability<MyGenericClass<TestClass?>>(NullabilityState.Nullable);

            CheckNullability<MyStringClass>(NullabilityState.NotNull);
            CheckNullability<MyNullableStringClass>(NullabilityState.Nullable);

            CheckNullability<MyIntClass>(NullabilityState.NotNull);
            CheckNullability<MyNullableIntClass>(NullabilityState.Nullable);

            CheckNullability<MyTestClassClass>(NullabilityState.NotNull);
            CheckNullability<MyNullableTestClassClass>(NullabilityState.Nullable);
        }

        static void CheckNullability<T>(NullabilityState expected)
        {
            PropertyInfo valueProperty = typeof(T).GetProperty("Value")!;
            NullabilityInfo NullabilityInfo = new NullabilityInfoContext().Create(valueProperty);

            if(expected != NullabilityInfo.ReadState)
            {
                Console.BackgroundColor = ConsoleColor.Yellow;
                Console.ForegroundColor = ConsoleColor.Black;
            }

            Console.WriteLine(
                "{0, -30} | {1, -10} | {2} ", 
                string.Format("{0}<{1}>", typeof(T).Name, string.Join(',', typeof(T).GetGenericArguments().Select(x => x.Name))), 
                expected, 
                NullabilityInfo.ReadState);

            if (expected != NullabilityInfo.ReadState)
                Console.ResetColor();
        }
    }
    public record MyGenericClass<T>(T Value);    
    public record MyStringClass(string Value);
    public record MyNullableStringClass(string? Value);
    public record MyIntClass(int Value);
    public record MyNullableIntClass(int? Value);
    public record MyTestClassClass(TestClass Value);
    public record MyNullableTestClassClass(TestClass? Value);
    public record TestClass { }
}
#nullable restore
@ghost ghost added the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Apr 24, 2025
@dotnet-policy-service dotnet-policy-service bot added the untriaged New issue has not been triaged by the area owner label Apr 24, 2025
@elgonzo
Copy link

elgonzo commented Apr 24, 2025

The desired result is impossible to get through reflection from the generic type itself unless MyGenericClass<ReferenceType> and MyGenericClass<ReferenceType?> would be distinguishable types at runtime, which however is not the case.

Note that MyGenericClass<string> is the same type as MyGenericClass<string?>. In other words, for reference type generic parameters ReferenceEquals(typeof(MyGenericClass<ReferenceType>), typeof(MyGenericClass<ReferenceType?>) is true.

If you need this at runtime without doing source code analysis/Roslyn, as far as reference types as generic arguments are concerned, one approach is finding some field/property/method that is using the type MyGenericClass<T>/MyGenericClass<T?> and then not inspect the type but rather the custom attributes of that field, property or the respective method parameter (or return parameter). That's how the nullability information for such generic reference type arguments is carried despite the generic type itself not being able to define the nullability of its generic reference type arguments. You don't query the generic type, you query users of the generic type to get this information. NullabilityInfoContext can help you also with that.

So, if you for example have some method somewhere like:

public class Bar
{
    public MyGenericClass<string> Foo() { ... }
}

then you can query the return parameter (not the generic type!) to see whether it is a generic type with nullable/non-nullable generic arguments. E.g.:

var returnParameter = typeof(Bar).GetMethod("Foo", BindingFlags.Public | BindingFlags.Instance).ReturnParameter;
NullabilityInfo ni = new NullabilityInfoContext().Create(returnParameter);
Console.WriteLine(ni.GenericTypeArguments[0].ReadState);

(Sharplab.io example)

@jkotas jkotas added area-System.Reflection question Answer questions and provide assistance, not an issue with source code or documentation. and removed needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners labels Apr 25, 2025
Copy link
Contributor

Tagging subscribers to this area: @dotnet/area-system-reflection
See info in area-owners.md if you want to be subscribed.

@ShawnOwczarzak
Copy link
Author

That explains everything I am seeing. 🙂👍

Unfortunate since I'm in a JsonConverter and needing the PropertyInfo of the property on the parent class means handling the deserialization there, instead of just needing the generic Type to convert. But the snippet you provided is a great starting point 💯

Thank you @elgonzo for the quick and very clear response!

@dotnet-policy-service dotnet-policy-service bot removed the untriaged New issue has not been triaged by the area owner label Apr 25, 2025
@github-actions github-actions bot locked and limited conversation to collaborators May 27, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-System.Reflection question Answer questions and provide assistance, not an issue with source code or documentation.
Projects
None yet
Development

No branches or pull requests

3 participants