From 90ed195999977991aecc567cc3e7a897a8d81ff3 Mon Sep 17 00:00:00 2001 From: Aaron R Robinson Date: Mon, 9 Mar 2026 18:17:47 -0700 Subject: [PATCH] Add Type.GetNullableUnderlyingType() virtual API Add a new public virtual Type.GetNullableUnderlyingType() method that returns the underlying type T for Nullable, or null otherwise. Nullable.GetUnderlyingType() now forwards to this virtual method. This follows the same pattern as Enum.GetUnderlyingType() forwarding to Type.GetEnumUnderlyingType(), enabling Type subclasses like MetadataLoadContext's RoType to provide correct implementations. Changes: - Type.cs: New virtual with ReferenceEquals default (works for RuntimeType) - Nullable.cs: Forward GetUnderlyingType to the new virtual - RoType.cs: Override using CoreType.NullableT identity comparison - RuntimeType.Mono.cs: Update IsNullableOfT to use new virtual - System.Runtime.cs: Add API to ref assembly - NullableTests.cs: Tests for both RuntimeType and MLC paths All 24 NullableTests + 267 NullabilityInfoContextTests pass. Fixes dotnet#124216 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Nullable.cs | 11 +-- .../System.Private.CoreLib/src/System/Type.cs | 21 ++++++ .../Reflection/TypeLoading/Types/RoType.cs | 17 +++++ .../System.Runtime/ref/System.Runtime.cs | 1 + .../System.Runtime.Tests.csproj | 2 + .../System/NullableTests.cs | 69 +++++++++++++++++++ .../src/System/RuntimeType.Mono.cs | 2 +- 7 files changed, 112 insertions(+), 11 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Nullable.cs b/src/libraries/System.Private.CoreLib/src/System/Nullable.cs index 2eb6e5002968a2..a6701f15aafa1c 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Nullable.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Nullable.cs @@ -104,16 +104,7 @@ public static bool Equals(T? n1, T? n2) where T : struct { ArgumentNullException.ThrowIfNull(nullableType); - if (nullableType.IsGenericType && !nullableType.IsGenericTypeDefinition) - { - // Instantiated generic type only - Type genericType = nullableType.GetGenericTypeDefinition(); - if (ReferenceEquals(genericType, typeof(Nullable<>))) - { - return nullableType.GetGenericArguments()[0]; - } - } - return null; + return nullableType.GetNullableUnderlyingType(); } /// diff --git a/src/libraries/System.Private.CoreLib/src/System/Type.cs b/src/libraries/System.Private.CoreLib/src/System/Type.cs index 5a80ac4a18553a..c35e4149d4e700 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Type.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Type.cs @@ -602,6 +602,27 @@ protected virtual TypeCode GetTypeCodeImpl() public virtual bool IsInstanceOfType([NotNullWhen(true)] object? o) => o != null && IsAssignableFrom(o.GetType()); public virtual bool IsEquivalentTo([NotNullWhen(true)] Type? other) => this == other; + /// + /// Returns the underlying type argument of a type. + /// + /// + /// The type argument of the type if the current type represents + /// a closed generic ; otherwise, . + /// + public virtual Type? GetNullableUnderlyingType() + { + if (IsGenericType && !IsGenericTypeDefinition) + { + Type genericType = GetGenericTypeDefinition(); + if (ReferenceEquals(genericType, typeof(Nullable<>))) + { + return GetGenericArguments()[0]; + } + } + + return null; + } + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2085:UnrecognizedReflectionPattern", Justification = "The single instance field on enum types is never trimmed")] [Intrinsic] diff --git a/src/libraries/System.Reflection.MetadataLoadContext/src/System/Reflection/TypeLoading/Types/RoType.cs b/src/libraries/System.Reflection.MetadataLoadContext/src/System/Reflection/TypeLoading/Types/RoType.cs index ced9b2e4c22efc..339bdff2ff3139 100644 --- a/src/libraries/System.Reflection.MetadataLoadContext/src/System/Reflection/TypeLoading/Types/RoType.cs +++ b/src/libraries/System.Reflection.MetadataLoadContext/src/System/Reflection/TypeLoading/Types/RoType.cs @@ -327,6 +327,23 @@ public sealed override Type MakeArrayType(int rank) private volatile RoType? _lazyUnderlyingEnumType; public sealed override Array GetEnumValues() => throw new InvalidOperationException(SR.Arg_InvalidOperation_Reflection); + // Nullable methods +#if NET + public sealed override Type? GetNullableUnderlyingType() + { + if (IsConstructedGenericType) + { + RoType? nullableOfT = Loader.TryGetCoreType(CoreType.NullableT); + if (nullableOfT is not null && GetGenericTypeDefinition() == nullableOfT) + { + return GenericTypeArguments[0]; + } + } + + return null; + } +#endif + #if NET [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2085:UnrecognizedReflectionPattern", Justification = "Enum Types are not trimmed.")] diff --git a/src/libraries/System.Runtime/ref/System.Runtime.cs b/src/libraries/System.Runtime/ref/System.Runtime.cs index 63b0c03545ff9a..2408c55ac31e63 100644 --- a/src/libraries/System.Runtime/ref/System.Runtime.cs +++ b/src/libraries/System.Runtime/ref/System.Runtime.cs @@ -6604,6 +6604,7 @@ protected Type() { } public virtual string? GetEnumName(object value) { throw null; } public virtual string[] GetEnumNames() { throw null; } public virtual System.Type GetEnumUnderlyingType() { throw null; } + public virtual System.Type? GetNullableUnderlyingType() { throw null; } [System.Diagnostics.CodeAnalysis.RequiresDynamicCodeAttribute("It might not be possible to create an array of the enum type at runtime. Use Enum.GetValues or the GetEnumValuesAsUnderlyingType method instead.")] public virtual System.Array GetEnumValues() { throw null; } public virtual System.Array GetEnumValuesAsUnderlyingType() { throw null; } diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System.Runtime.Tests.csproj b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System.Runtime.Tests.csproj index ce7a42a7f69699..ab693312b602ea 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System.Runtime.Tests.csproj +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System.Runtime.Tests.csproj @@ -376,5 +376,7 @@ + + diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/NullableTests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/NullableTests.cs index 4f3efc786e4623..dd342f4e71aff8 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/NullableTests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/NullableTests.cs @@ -3,7 +3,10 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using Xunit; namespace System.Tests @@ -88,12 +91,78 @@ public static void GetUnderlyingType(Type nullableType, Type? expected) Assert.Equal(expected, Nullable.GetUnderlyingType(nullableType)); } + [Theory] + [InlineData(typeof(int?), typeof(int))] + [InlineData(typeof(int), null)] + [InlineData(typeof(G), null)] + public static void GetNullableUnderlyingType_RuntimeType(Type type, Type? expected) + { + Assert.Equal(expected, type.GetNullableUnderlyingType()); + } + [Fact] public static void GetUnderlyingType_NullType_ThrowsArgumentNullException() { AssertExtensions.Throws("nullableType", () => Nullable.GetUnderlyingType((Type)null)); } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.HasAssemblyFiles))] + public static void GetUnderlyingType_MetadataLoadContext_NullableInt_ReturnsUnderlyingType() + { + string[] runtimeAssemblies = Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll"); + var resolver = new PathAssemblyResolver(runtimeAssemblies); + using var mlc = new MetadataLoadContext(resolver); + + Assembly coreAssembly = mlc.LoadFromAssemblyName("System.Runtime"); + Type intType = coreAssembly.GetType("System.Int32")!; + Type nullableIntType = coreAssembly.GetType("System.Nullable`1")!.MakeGenericType(intType); + + // Test via Nullable.GetUnderlyingType (forwards to the virtual) + Type? underlying = Nullable.GetUnderlyingType(nullableIntType); + Assert.NotNull(underlying); + Assert.Equal("System.Int32", underlying.FullName); + + // Test via Type.GetNullableUnderlyingType directly + Type? underlyingDirect = nullableIntType.GetNullableUnderlyingType(); + Assert.NotNull(underlyingDirect); + Assert.Equal("System.Int32", underlyingDirect.FullName); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.HasAssemblyFiles))] + public static void GetUnderlyingType_MetadataLoadContext_NonNullableTypes_ReturnsNull() + { + string[] runtimeAssemblies = Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll"); + var resolver = new PathAssemblyResolver(runtimeAssemblies); + using var mlc = new MetadataLoadContext(resolver); + + Assembly coreAssembly = mlc.LoadFromAssemblyName("System.Runtime"); + Type intType = coreAssembly.GetType("System.Int32")!; + Type stringType = coreAssembly.GetType("System.String")!; + Type kvpType = coreAssembly.GetType("System.Collections.Generic.KeyValuePair`2")!.MakeGenericType(intType, stringType); + + Assert.Null(Nullable.GetUnderlyingType(intType)); + Assert.Null(Nullable.GetUnderlyingType(stringType)); + Assert.Null(Nullable.GetUnderlyingType(kvpType)); + + Assert.Null(intType.GetNullableUnderlyingType()); + Assert.Null(stringType.GetNullableUnderlyingType()); + Assert.Null(kvpType.GetNullableUnderlyingType()); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.HasAssemblyFiles))] + public static void GetUnderlyingType_MetadataLoadContext_OpenNullable_ReturnsNull() + { + string[] runtimeAssemblies = Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll"); + var resolver = new PathAssemblyResolver(runtimeAssemblies); + using var mlc = new MetadataLoadContext(resolver); + + Assembly coreAssembly = mlc.LoadFromAssemblyName("System.Runtime"); + Type openNullableType = coreAssembly.GetType("System.Nullable`1")!; + + Assert.Null(Nullable.GetUnderlyingType(openNullableType)); + Assert.Null(openNullableType.GetNullableUnderlyingType()); + } + [Fact] public static void GetValueRefOrDefaultRef_WithValue() { diff --git a/src/mono/System.Private.CoreLib/src/System/RuntimeType.Mono.cs b/src/mono/System.Private.CoreLib/src/System/RuntimeType.Mono.cs index f442f500bcd5b4..e656c90f04fa4e 100644 --- a/src/mono/System.Private.CoreLib/src/System/RuntimeType.Mono.cs +++ b/src/mono/System.Private.CoreLib/src/System/RuntimeType.Mono.cs @@ -2466,7 +2466,7 @@ public override string? FullName public sealed override bool HasSameMetadataDefinitionAs(MemberInfo other) => HasSameMetadataDefinitionAsCore(other); - internal bool IsNullableOfT => Nullable.GetUnderlyingType(this) != null; + internal bool IsNullableOfT => GetNullableUnderlyingType() is not null; public override bool IsSZArray {