Skip to content

[Breaking change]: Nullable.GetUnderlyingType throws on custom Type subclasses that don't override Type.GetNullableUnderlyingType #53407

@AaronRobinsonMSFT

Description

@AaronRobinsonMSFT

Description

In .NET 11, a new public virtual method Type.GetNullableUnderlyingType() was added on System.Type, and Nullable.GetUnderlyingType(Type) now forwards to it. Custom Type subclasses (those derived directly or indirectly from System.Type) that do not override the new virtual will throw NotSupportedException when passed to Nullable.GetUnderlyingType, where they previously returned null.

Tracking PR: dotnet/runtime#126905.

Version

.NET 11 Preview 4

Previous behavior

Nullable.GetUnderlyingType inspected the input type using IsGenericType and GetGenericTypeDefinition, returning null for any type that wasn't a constructed Nullable<T>. Custom Type subclasses that did not specialize Nullable handling would silently return null.

class MyType : Type { /* ... */ }

Type t = new MyType();
Type? u = Nullable.GetUnderlyingType(t); // returned null

New behavior

Nullable.GetUnderlyingType(Type) now forwards to the new Type.GetNullableUnderlyingType() virtual. The base implementation on System.Type throws NotSupportedException so subclass authors are required to opt in. Custom Type subclasses that do not override the virtual will throw:

class MyType : Type { /* does not override GetNullableUnderlyingType */ }

Type t = new MyType();
Type? u = Nullable.GetUnderlyingType(t);
// throws System.NotSupportedException:
//   "Derived classes must provide an implementation."

The in-box Type subclasses shipped by .NET (RuntimeType, TypeDelegator, TypeBuilder, EnumBuilder, GenericTypeParameterBuilder, TypeBuilderInstantiation, SymbolType, ModifiedType, the SignatureType family, and MetadataLoadContext's RoType) all override the new virtual and are unaffected.

Type of breaking change

  • Behavioral change: Existing binaries might behave differently at run time.

Reason for change

Nullable.GetUnderlyingType previously hard-coded a comparison against the runtime's typeof(Nullable<>). This produced incorrect results for Type subclasses that represent types from a different reflection universe — most notably MetadataLoadContext, where Nullable<T> was always reported as a non-nullable type (dotnet/runtime#124216). The new virtual gives each Type implementation a hook to identify Nullable types correctly within its own reflection model, mirroring the long-standing Type.GetEnumUnderlyingType() pattern.

The base implementation throws (rather than returning null) by design so that custom Type subclass authors are notified that they need to provide a correct answer for their domain.

Recommended action

If you author a custom Type subclass, add an override of GetNullableUnderlyingType():

  • If your subclass never represents a Nullable<T>, override to return null:

    public override Type? GetNullableUnderlyingType() => null;
  • If your subclass represents constructed generic types and may instantiate Nullable<T>, return the corresponding type argument:

    public override Type? GetNullableUnderlyingType()
    {
        if (IsGenericType && !IsGenericTypeDefinition
            && GetGenericTypeDefinition() == typeof(Nullable<>))
        {
            return GetGenericArguments()[0];
        }
        return null;
    }
  • If your subclass is a delegating wrapper around another Type, forward the call:

    public override Type? GetNullableUnderlyingType() => _innerType.GetNullableUnderlyingType();

Compiling against .NET 11 surfaces the new virtual on Type, making the override discoverable. No app/configuration switch reverts to the old behavior.

Feature area

Core .NET libraries

Affected APIs

  • System.Nullable.GetUnderlyingType(System.Type)
  • System.Type.GetNullableUnderlyingType() (new virtual; default throws NotSupportedException)

Note

This issue was drafted with assistance from GitHub Copilot.


Associated WorkItem - 573384

Metadata

Metadata

Labels

📌 seQUESTeredIdentifies that an issue has been imported into Quest.breaking-changeIndicates a .NET Core breaking change

Type

No type
No fields configured for issues without a type.

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions