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:
NullableAttribute: Applied to specific members, parameters, return types, or fields. It contains abyte[]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 likeDictionary<string, List<int?>?>, the byte array stores a traversal of the type tree to represent the nullability of each component.
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, or2) that establishes the default nullability context for any member within that scope that does not have an explicitNullableAttribute.
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
NullableAttributeon 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
NullableContextAttributeif 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: NullableThe 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 ofNullabilityInfoinstances representing the nested generic arguments, accurately handling structures likeList<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
nullfor 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
NullabilityInfoContextshould be cached and reused when analyzing metadata across high-throughput paths.