C# Nullable Reference Types
When to Use
- Introducing nullable reference types (NRT) into a codebase that has not yet adopted them
- Writing or refactoring C# code that uses / nullable annotations
- Annotating APIs with
System.Diagnostics.CodeAnalysis
nullable attributes
- Designing public/internal APIs where nullability contracts matter
- Wrapping unannotated or legacy APIs so downstream callers still benefit from NRT
- Reviewing code for correct null-state analysis, guard helpers, and the keyword
Core Goals
- Prevent at runtime by making null intent explicit in signatures.
- Express contracts the type system cannot represent directly using the official nullable attributes.
- Adopt NRT incrementally in legacy codebases without a big-bang rewrite.
Core Nullability Model
Non-nullable vs nullable
- — non-nullable reference. The compiler assumes instances are never ; assigning or a maybe-null value produces a warning.
- — nullable reference. The variable may be ; the compiler requires a null check before dereference.
csharp
string name = "Alice";
name = null; // Warning: assigning null to non-nullable.
string? nickname = null;
Console.WriteLine(nickname.Length); // Warning: possible null dereference.
Null-state analysis (flow)
The compiler tracks whether a reference is definitely non-null or maybe null. Null checks and assignments update this state.
csharp
string? message = GetMessageOrNull();
if (message != null)
{
// message is definitely non-null in this block.
Console.WriteLine(message.Length);
}
// Outside the if, message is maybe null again.
Introduce explicit null checks (
,
, pattern matching) before dereferencing nullable values. Narrow nullability early and keep the non-null state alive.
Null-conditional assignment (C# 14) lets you write
customer?.Order = CreateOrder();
— the right side is evaluated only when the receiver is non-null.
Null-forgiving operator ()
tells the compiler "treat
as non-null here." It affects analysis only, not runtime behavior.
- Use only when a real invariant guarantees non-null and the compiler cannot see it.
- Do not use as a general fix for warnings. Prefer refactoring control flow, adding attributes, or proper member initialization.
csharp
_customer = LoadCustomerFromOrm()!; // ORM guarantees this is not null in valid state.
Reorganize code before suppressing warnings
A successful guard clause or pattern match already creates a null-safe region in the current scope. Before adding
, make nullable values cross a checked boundary once and keep the remaining code non-nullable:
- narrow early with a guard clause or pattern match;
- copy nullable fields or properties to a local before checking, so repeated reads cannot change underneath the analysis;
- when a method has complex control flow, optionally move the non-null path into a local function or private method with non-nullable parameters;
- keep nullable handling at the boundary instead of spreading , repeated checks, or through the implementation.
csharp
public void Process(Order? order)
{
if (order?.Customer is not { } customer)
{
return;
}
// The pattern match already proved that customer is non-null here.
Console.WriteLine(customer.Name);
}
Do not extract a function solely to satisfy nullable analysis. Use an explicit non-nullable function boundary when it also simplifies a large or branching implementation. Use
only when a real external invariant cannot be represented through control flow, signatures, or nullable-analysis attributes.
Project Configuration
Enable NRT for new code:
xml
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
For legacy codebases, enable incrementally with file-level directives (
,
,
#nullable enable warnings
,
#nullable enable annotations
). Treat
nullable warnings as important; consider
or treating nullable warnings as errors in new projects.
See
nrt-migration-playbook-reference.md for the full incremental-adoption strategy,
directive reference, legacy interop, and known static-analysis limitations (arrays,
).
API Design Rules (Signatures)
These rules apply to public and internal APIs and to models.
Parameters — if
is not allowed, use a non-nullable type and add a runtime guard for public APIs:
csharp
public void SendEmail(string recipient)
{
ArgumentNullException.ThrowIfNull(recipient);
// Implementation
}
If
is allowed and meaningful, use
, document how
is interpreted, and implement correct
behavior.
Return types —
when the method never returns
;
when it can legitimately return
(callers must check, and the compiler enforces it).
csharp
public Customer GetRequiredCustomer(Guid id); // Throws on failure.
public Customer? TryGetCustomer(Guid id); // Returns null on failure.
Properties and fields — follow the same rules as parameters and return types. Non-nullable members must be initialized in constructors, via
properties with object initializers, via
-backed lazy properties, or via helpers annotated with
.
csharp
public class Order
{
public required string Id { get; init; }
public required Customer Customer { get; init; }
public string? Comment { get; init; } // Optional.
}
When contracts depend on input/output behavior, conditional behavior, or member initialization, apply the nullable attributes described in nullable-attributes-reference.md.
Public API compatibility for libraries
Treat nullable annotations and nullable-analysis attributes as part of a shipped API contract.
and
have the same CLR type, so annotation-only changes are generally binary compatible, but they can be source breaking by introducing warnings for nullable-enabled consumers. Those warnings often become build failures when consumers treat warnings as errors.
Review public nullability changes before release, especially:
- weakening an output from to or adding ;
- tightening an input from to or adding ;
- changing generic constraints such as , , or ;
- changing annotations or attributes on virtual members, interfaces, delegates, and implementations, where mismatches produce compiler warnings.
Adding annotations to a previously nullable-oblivious API can create the same source-compatibility problems. Compare the annotated surface with the last released version, test a nullable-enabled consumer, and document or version intentional source-breaking changes according to the library's compatibility policy.
The Keyword (C# 14 / .NET 10)
The
contextual keyword lets you write a property accessor body without declaring an explicit backing field. This is a primary NRT scenario (lazily-initialized properties) and the compiler performs a special
null-resilience analysis so you do not get nuisance
in constructors:
csharp
public class C
{
public C() { } // No warning: the getter is null-resilient.
string Prop => field ??= GetPropValue();
}
See
nullable-attributes-reference.md for the full
nullability rules (null-resilient vs non-resilient getters, the
[field: AllowNull, MaybeNull]
escape hatch, and setter/constructor analysis).
Reference Files
- nullable-attributes-reference.md: The complete
System.Diagnostics.CodeAnalysis
attribute catalog — preconditions (, ), postconditions (, ), conditional postconditions (, , ), helper methods (, ), unreachable-code helpers (, ), and the keyword nullability rules. Each with intent, pattern, and agent rules.
- nrt-migration-playbook-reference.md: Incremental adoption strategy, directive reference, legacy/unannotated API interop, polyfilling nullable attributes for older target frameworks (with tradeoffs and a confirm-before-adding decision process), known static-analysis limitations (arrays of non-nullable references, with reference fields), warning handling, and the full generation checklist.
Generation Checklist (Summary)
- Project —
<Nullable>enable</Nullable>
present; disable only around unavoidable legacy code.
- Types — non-nullable for required params/returns/properties; only when is valid and expected.
- Initialization — constructors, + object initializers, -backed lazy getters, or helpers. Avoid except as a documented escape hatch.
- Null checks — explicit guards at public boundaries; narrow with control flow, and extract a non-nullable helper only when it improves complex code; only with a clear invariant.
- Attributes — apply to express contracts the type system cannot express (see reference file).
- Interop — trust BCL/annotated libraries; add your own guards and attributes when wrapping unannotated APIs.
- Warnings — never ignore; fix design or add attributes rather than suppressing with or .
- Compatibility — for released libraries, review public nullability changes as potential source breaks and test nullable-enabled consumers.
References