Skip to content

MetadataLoadContext: Nullable.GetUnderlyingType() always returns null #124216

@smdn

Description

@smdn

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>.

https://github.com/dotnet/dotnet/blob/b0f34d51fccc69fd334253924abd8d6853fad7aa/src/runtime/src/libraries/System.Private.CoreLib/src/System/Nullable.cs#L103-L117

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.)

https://github.com/dotnet/dotnet/blob/b0f34d51fccc69fd334253924abd8d6853fad7aa/src/runtime/src/libraries/System.Private.CoreLib/src/System/Reflection/NullabilityInfoContext.cs#L350-L357

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions