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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions