In .NET 6 onwards, we gained the NullabilityInfoContext
type, see this answer for details.
Prior to this, you have to manually inspect the attributes yourself. This seems to do the trick:
public static bool GenericReturnTypeIsNullable(MethodInfo methodInfo)
{
var nullableAttribute = methodInfo!.ReturnTypeCustomAttributes.GetCustomAttributes(true)
.FirstOrDefault(x => x.GetType().FullName == "System.Runtime.CompilerServices.NullableAttribute");
if (nullableAttribute != null)
{
var flagsField = nullableAttribute.GetType().GetField("NullableFlags");
var flags = (byte[]?)flagsField?.GetValue(nullableAttribute);
return flags != null && flags.Length >= 2 && flags[1] == 2;
}
if (CheckNullableContext(methodInfo.CustomAttributes))
return true;
for (var type = methodInfo.DeclaringType; type != null; type = type.DeclaringType)
{
if (CheckNullableContext(type.CustomAttributes))
return true;
}
static bool CheckNullableContext(IEnumerable<CustomAttributeData> customAttributes)
{
var context = customAttributes.FirstOrDefault(x => x.AttributeType.FullName == "System.Runtime.CompilerServices.NullableContextAttribute");
return context != null &&
context.ConstructorArguments.Count == 1 &&
context.ConstructorArguments[0].ArgumentType == typeof(byte) &&
(byte)context.ConstructorArguments[0].Value! == 2;
}
return false;
}
See it on dotnetfiddle.net.
See this spec doc for details.
We end up needing to check a number of things.
If the method has a Nullable
attribute on its return value, its NullableFlags
field is a byte array, where each member of the array refers to successive generic types. A 0
means null-oblivious, 1
means not nullable, 2
means nullable. So in:
[return: System.Runtime.CompilerServices.Nullable(new byte[] { 1, 2 })]
public Task<string?> Foo() => ...
The 1
refers to the Task<T>
itself, and indicates that it's not nullable. The 2
refers to the first generic type parameter, i.e. the string?
, and indicates that it is nullable.
If this attribute doesn't exist, we need to check for a NullableContext
attribute on the method itself. This provides a default nullability for everything which doesn't have a specified Nullable
attribute. This takes a single byte, again 0
to 2
.
If the method doesn't have this attribute, it might be on the containing type, or its containing type. Keep looking upwards until we find one (or not).
The slightly tricky bit is that the compiler emits the Nullable
and NullableContext
attributes directly into the assembly, which means that each assembly will have its own definition. So we need to use reflection to access them - we can't cast anything to a NullableAttribute
or NullableContextAttribute
.