How Nullable Annotations Affect C# Reflection

Nullable reference types, introduced in C# 8.0, are a compile-time safety feature rather than a runtime type system change. Because the Common Language Runtime (CLR) treats string and string? identically as System.String, nullability state cannot be determined by checking an object's runtime Type. Instead, the C# compiler embeds nullability metadata directly into assemblies using specific metadata attributes. This article explores how the compiler encodes this metadata, the challenges it presents for standard reflection, and how modern .NET APIs allow developers to accurately inspect nullability at runtime.

The Underlying Metadata Mechanism

To preserve backward compatibility, the runtime does not introduce new nullable CLR types for reference types. Instead, the compiler synthesizes internal attributes in the System.Runtime.CompilerServices namespace:

  1. NullableAttribute: Applied to specific members, parameters, return types, or fields. It contains a byte[] flag payload indicating nullability:
    • 0: Oblivious (unannotated or pre-C# 8 code).
    • 1: Not null (e.g., string).
    • 2: Nullable (e.g., string?). For complex generic types like Dictionary<string, List<int?>?>, the byte array stores a traversal of the type tree to represent the nullability of each component.
  2. NullableContextAttribute: To minimize metadata size in compiled assemblies, the compiler applies this attribute to entire classes, structs, methods, or assemblies. It contains a single byte (0, 1, or 2) that establishes the default nullability context for any member within that scope that does not have an explicit NullableAttribute.

Challenges with Traditional Reflection

Using traditional reflection methods—such as MemberInfo.GetCustomAttributes()—to inspect nullability is notoriously complex. To determine whether a property is nullable using manual attribute inspection, you must:

  • Check for an explicit NullableAttribute on the target property.
  • Decode its nested byte array if generics or arrays are involved.
  • Fall back to the enclosing method, declaring type, or assembly to look for a NullableContextAttribute if the member lacks an explicit attribute.
  • Account for differing read/write nullability (such as a property with a nullable getter but a non-nullable setter).

Due to these layered rules, manually parsing the metadata attributes is error-prone and inefficient.

The Modern Solution: NullabilityInfoContext

Starting in .NET 6, the runtime introduced System.Reflection.NullabilityInfoContext to eliminate the complexity of manual attribute parsing. This utility automates the resolution of both NullableAttribute and NullableContextAttribute across scopes.

using System.Reflection;

public class Example
{
    public string NonNullProp { get; set; } = string.Empty;
    public string? NullableProp { get; set; }
}

var context = new NullabilityInfoContext();
PropertyInfo propInfo = typeof(Example).GetProperty(nameof(Example.NullableProp))!;
NullabilityInfo nullInfo = context.Create(propInfo);

Console.WriteLine(nullInfo.ReadState);  // Outputs: Nullable
Console.WriteLine(nullInfo.WriteState); // Outputs: Nullable

The resulting NullabilityInfo object exposes properties including:

  • ReadState: The nullability of the value when read (e.g., getter return value).
  • WriteState: The nullability requirements when written (e.g., setter value parameter).
  • GenericTypeArguments: An array of NullabilityInfo instances representing the nested generic arguments, accurately handling structures like List<string?>.

Practical Implications for Modern Frameworks

For libraries heavily reliant on reflection—such as object mappers, serialization engines (e.g., System.Text.Json), and validation frameworks—nullability annotations fundamentally change runtime behavior:

  • Deserialization: Modern deserializers can enforce non-nullable reference types at runtime, throwing exceptions if incoming JSON payloads contain null for a non-nullable property.
  • Validation and ORMs: Tools like Entity Framework Core leverage nullability metadata to configure database column nullability automatically during schema generation.
  • Performance Overhead: Inspecting nullability metadata involves extra reflection operations and allocation overhead. As a best practice, instances of NullabilityInfoContext should be cached and reused when analyzing metadata across high-throughput paths.