-
Notifications
You must be signed in to change notification settings - Fork 5.4k
Description
Description
When calling Nullable.GetUnderlyingType() on a Type object obtained via MetadataLoadContext, it returns null even if the type is Nullable<T>.
This occurs because Nullable.GetUnderlyingType() identifies Nullable<T> by performing a ReferenceEquals check against typeof(Nullable<>).
In the context of MetadataLoadContext, the input is a RoType instance, while typeof(Nullable<>) in CoreLib is a RuntimeType.
Consequently, this comparison always returns false, leading the method to incorrectly conclude that the type is not a Nullable<T>.
Furthermore, because NullabilityInfoContext relies on Nullable.GetUnderlyingType() for nullability validation,
it produces incorrect NullabilityInfo results for types loaded via MetadataLoadContext.
(Please refer to the reproduction output below.)
Reproduction Steps
By executing the following reproduction code, you can identify the parts where results differ depending on whether MetadataLoadContext is used or not.
#nullable enable
using System.Reflection;
using System.Runtime.InteropServices;
using var mlc = new MetadataLoadContext(
new PathAssemblyResolver([
Assembly.GetExecutingAssembly().Location,
.. Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll")
])
);
var mlcExecutingAssembly = mlc.LoadFromAssemblyPath(Assembly.GetExecutingAssembly().Location);
var executingAssembly = Assembly.GetExecutingAssembly();
// Here, we inspect the types of fields in class C (see below).
// For comparison, we display the results of Nullable.GetUnderlyingType and NullabilityInfo
// for FieldInfo obtained from both the standard assembly and the MetadataLoadContext.
foreach (var f in new[] {
executingAssembly.GetType(typeof(C).FullName!, throwOnError: true)!.GetField(nameof(C.FSimple))!,
mlcExecutingAssembly.GetType(typeof(C).FullName!, throwOnError: true)!.GetField(nameof(C.FSimple))!,
executingAssembly.GetType(typeof(C).FullName!, throwOnError: true)!.GetField(nameof(C.FTuple))!,
mlcExecutingAssembly.GetType(typeof(C).FullName!, throwOnError: true)!.GetField(nameof(C.FTuple))!,
}) {
var info = new NullabilityInfoContext().Create(f);
Console.WriteLine($"{f.Name} ({f.FieldType}, {f.FieldType.GetType()})");
Console.WriteLine($" UnderlyingType: {Nullable.GetUnderlyingType(f.FieldType)}");
Console.WriteLine($" ReadState: {info.ReadState}");
Console.WriteLine($" GenericTypeArguments: {string.Join(", ", (object[])info.GenericTypeArguments)}");
for (var i = 0; i < info.GenericTypeArguments.Length; i++) {
var typeArgInfo = info.GenericTypeArguments[i];
Console.WriteLine($" [{i}]: {typeArgInfo.Type.Name} ({typeArgInfo.Type.GetType()})");
Console.WriteLine($" UnderlyingType: {Nullable.GetUnderlyingType(typeArgInfo.Type)}");
Console.WriteLine($" ReadState: {typeArgInfo.ReadState}");
}
Console.WriteLine();
}
class C {
public int? FSimple = default;
public (int, int?, string, string?) FTuple = default;
}Expected behavior
Refer to the "Actual behavior" section for the expected behavior.
Actual behavior
The reproduction code above produces the following output. Key discrepancies are marked with 👈.
FSimple (System.Nullable`1[System.Int32], System.RuntimeType)
UnderlyingType: System.Int32
ReadState: Nullable
GenericTypeArguments:
FSimple (System.Nullable`1[System.Int32], System.Reflection.TypeLoading.RoConstructedGenericType) 👈 FieldInfo for the same field, but obtained via MetadataLoadContext
UnderlyingType: 👈 ❌ Underlying type is incorrectly null
ReadState: NotNull 👈 ❌ NullabilityState is incorrectly NotNull instead of Nullable
GenericTypeArguments: System.Reflection.NullabilityInfo 👈 ❌ Nullable<T> is treated as a standard generic type
[0]: Int32 (System.Reflection.TypeLoading.Ecma.EcmaDefinitionType)
UnderlyingType:
ReadState: NotNull
FTuple (System.ValueTuple`4[System.Int32,System.Nullable`1[System.Int32],System.String,System.String], System.RuntimeType)
UnderlyingType:
ReadState: NotNull
GenericTypeArguments: System.Reflection.NullabilityInfo, System.Reflection.NullabilityInfo, System.Reflection.NullabilityInfo, System.Reflection.NullabilityInfo
[0]: Int32 (System.RuntimeType)
UnderlyingType:
ReadState: NotNull
[1]: Nullable`1 (System.RuntimeType)
UnderlyingType: System.Int32
ReadState: Nullable
[2]: String (System.RuntimeType)
UnderlyingType:
ReadState: NotNull
[3]: String (System.RuntimeType)
UnderlyingType:
ReadState: Nullable
FTuple (System.ValueTuple`4[System.Int32,System.Nullable`1[System.Int32],System.String,System.String], System.Reflection.TypeLoading.RoConstructedGenericType) 👈 FieldInfo for the same field, but obtained via MetadataLoadContext
UnderlyingType: 👈✅ as expected
ReadState: NotNull 👈✅ as expected
GenericTypeArguments: System.Reflection.NullabilityInfo, System.Reflection.NullabilityInfo, System.Reflection.NullabilityInfo, System.Reflection.NullabilityInfo
[0]: Int32 (System.Reflection.TypeLoading.Ecma.EcmaDefinitionType)
UnderlyingType: 👈✅ as expected
ReadState: NotNull 👈✅ as expected
[1]: Nullable`1 (System.Reflection.TypeLoading.RoConstructedGenericType)
UnderlyingType: 👈❌ Underlying type is incorrectly null
ReadState: NotNull 👈❌ NullabilityState is incorrectly NotNull instead of Nullable
[2]: String (System.Reflection.TypeLoading.Ecma.EcmaDefinitionType)
UnderlyingType:
ReadState: Nullable 👈❌ Incorrect NullabilityState due to mismatch with the mapping in NullableAttribute.NullableFlags
[3]: String (System.Reflection.TypeLoading.Ecma.EcmaDefinitionType)
UnderlyingType:
ReadState: Unknown 👈❌ NullabilityState becomes inaccessible due to the index mismatch with NullableAttribute.NullableFlags
As shown above, Nullable.GetUnderlyingType() unexpectedly returns null.
Consequently, NullabilityInfo incorrectly identifies Nullable<T> as NullabilityState.NotNull, similar to other generic types.
Additionally, this causes an index mismatch between NullableAttribute.NullableFlags and NullabilityInfo.GenericTypeArguments.
This leads to incorrect nullability reports for generic types where Nullable<T> is used as a type argument, such as ValueTuple<int, int?, string, string?>.
Regression?
No response
Known Workarounds
For Nullable.GetUnderlyingType(), this issue can be avoided by comparing type names instead of using ReferenceEquals with typeof(Nullable<>):
static Type? GetNullableUnderlyingType(Type nullableType)
{
ArgumentNullException.ThrowIfNull(nullableType);
if (!nullableType.IsGenericType)
return null;
if (nullableType.IsGenericTypeDefinition)
return null;
var genericTypeDef = nullableType.GetGenericTypeDefinition();
if (
genericTypeDef.Namespace == "System" &&
genericTypeDef.Name == "Nullable`1" &&
nullableType.GetGenericArguments() is [var underlyingType]
) {
return underlyingType;
}
return null;
}Configuration
$ dotnet --info
.NET SDK:
Version: 10.0.101
Commit: fad253f51b
Workload version: 10.0.100-manifests.1773493e
MSBuild version: 18.0.6+fad253f51
Runtime Environment:
OS Name: ubuntu
OS Version: 24.04
OS Platform: Linux
RID: ubuntu.24.04-x64
Base Path: /usr/lib/dotnet/sdk/10.0.101/
csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="10.0.2"/>
</ItemGroup>
</Project>
Other information
No response