C# Nullable Reference Types
The Null Problem
Before C# 8.0, any reference type could be null, and the compiler couldn’t help identify potential null dereferences.
// Without nullable reference types - compiles but crashes
string name = GetName(); // Might return null
int length = name.Length; // NullReferenceException if null
Nullable reference types (NRT) enable the compiler to track nullability and warn about potential null issues at compile time.
Enabling Nullable Reference Types
Project-Wide (Recommended)
<!-- In .csproj file -->
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
File-Level
#nullable enable // Enable for this file
// ... code ...
#nullable disable // Disable for rest of file
#nullable restore // Return to project default
Contextual Control
#nullable enable annotations // Enable annotations only
#nullable enable warnings // Enable warnings only
Nullable Annotations
Non-Nullable Reference Types (Default)
When NRT is enabled, reference types are non-nullable by default.
#nullable enable
public class Customer
{
public string Name { get; set; } // Cannot be null
public string Email { get; set; } // Cannot be null
public Customer(string name, string email)
{
Name = name; // OK
Email = email; // OK
}
}
// Compiler warnings
Customer c = null; // Warning: assigning null to non-nullable
string name = c.Name; // Warning: possible null reference
Nullable Reference Types (?)
Use ? to indicate a reference type can be null.
#nullable enable
public class Customer
{
public string Name { get; set; } // Required - cannot be null
public string? MiddleName { get; set; } // Optional - can be null
public string? Email { get; set; } // Optional - can be null
public Customer(string name)
{
Name = name;
// MiddleName and Email are null by default
}
}
// Usage
var customer = new Customer("Alice");
customer.MiddleName = null; // OK - explicitly nullable
// Must check before using nullable types
if (customer.MiddleName != null)
{
int length = customer.MiddleName.Length; // Safe - null checked
}
// Or use null-conditional
int? length = customer.MiddleName?.Length;
Null State Analysis
The compiler tracks whether a variable might be null and warns on unsafe access.
#nullable enable
public void ProcessCustomer(Customer? customer)
{
// customer might be null here
Console.WriteLine(customer.Name); // Warning: possible null dereference
if (customer == null)
return;
// compiler knows customer is not null after the check
Console.WriteLine(customer.Name); // OK - no warning
// Pattern matching also establishes null state
if (customer is { Name: var name })
{
Console.WriteLine(name); // OK - name is not null
}
}
Null Guard Patterns
public void Process(Customer? customer)
{
// Guard clause with throw
if (customer is null)
throw new ArgumentNullException(nameof(customer));
// customer is not null here
// Guard with return
if (customer is null) return;
// customer is not null here
// Pattern matching
if (customer is not null)
{
// customer is not null in this block
}
// Null-coalescing throw (C# 7.0+)
var validCustomer = customer ?? throw new ArgumentNullException(nameof(customer));
}
Null-Forgiving Operator (!)
Tell the compiler you know a value isn’t null when it can’t determine this.
#nullable enable
public class Service
{
private string? connectionString;
public void Initialize(string connection)
{
connectionString = connection;
}
public void DoWork()
{
// Compiler doesn't know connectionString was set
// Use ! to suppress warning when you're certain
var conn = connectionString!;
}
}
// Common scenarios for !
// After validation you know isn't tracked
var item = dictionary.TryGetValue(key, out var value) ? value! : default;
// After external initialization
[SetUp]
public void Setup()
{
service = CreateService(); // Test framework initializes
}
private IService service = null!; // Will be set in Setup
Use ! Sparingly
Overuse defeats the purpose of null safety. Prefer proper null checks or restructuring code.
Attributes for Advanced Scenarios
The ? annotation is binary: a type is either nullable or not. But real code has conditional nullability that ? alone can’t express, like “this parameter enters nullable but leaves non-null” or “the return is null only if the input is null.” These attributes from System.Diagnostics.CodeAnalysis bridge that gap, giving the compiler enough information to continue tracking null state across method boundaries.
MemberNotNull
The ? annotation can mark the logger field as nullable, but it can’t tell the compiler that calling a specific method guarantees the field gets set. MemberNotNull fills that gap: it promises that one or more fields will be non-null after the method returns, so the compiler stops warning when you access them afterward.
public class LazyService
{
private ILogger? logger;
[MemberNotNull(nameof(logger))]
private void EnsureInitialized()
{
logger ??= CreateLogger();
}
public void Log(string message)
{
EnsureInitialized();
logger.Log(message); // OK - compiler trusts attribute
}
}
NotNull
A ? annotation is fixed at declaration time: customer is either Customer? or Customer. It can’t express “this variable starts as nullable but becomes non-null after a method call.” NotNull marks a parameter that will be non-null when the method returns normally (throwing otherwise), which tells the compiler to narrow the caller’s variable from Customer? to Customer after the call. This is what makes reusable guard methods work with the null-state analyzer.
public static class Guard
{
public static void NotNull<T>([NotNull] T? value, string paramName) where T : class
{
if (value is null)
throw new ArgumentNullException(paramName);
}
}
public void Process(Customer? customer)
{
Guard.NotNull(customer, nameof(customer));
// customer is not null after this call
Console.WriteLine(customer.Name);
}
MaybeNull and NotNullWhen
MaybeNull handles a case where ? used to fall short in generics. With where T : class, callers pass a non-nullable type like string, but the method needs to return null when the key isn’t found. In C# 9+, you can write T? with a class constraint and the compiler understands it, which covers this exact scenario. MaybeNull is still needed for unconstrained generics, where T? means Nullable<T> for value types rather than “T or null” for reference types.
NotNullWhen expresses conditional nullability that ? has no way to represent: a parameter’s null state depends on the method’s return value. The TryGetValue pattern declares value as string? because it could be null, but the attribute tells the compiler that when the method returns true, value is guaranteed non-null. Without it, you’d need a null check after every successful TryGetValue call even though the value can’t be null at that point. This is the same pattern the BCL uses for Dictionary.TryGetValue and similar methods.
[return: MaybeNull]
public T Find<T>(string key) where T : class
{
return cache.ContainsKey(key) ? (T)cache[key] : null;
}
public bool TryGetValue(string key, [NotNullWhen(true)] out string? value)
{
return dictionary.TryGetValue(key, out value);
}
// Usage
if (TryGetValue("key", out var value))
{
Console.WriteLine(value.Length); // OK - value is not null
}
AllowNull and DisallowNull
The ? annotation applies to a property as a whole: string means non-nullable everywhere, string? means nullable everywhere. But sometimes the getter and setter need different null contracts, and ? can’t express that asymmetry.
AllowNull lets a non-nullable property accept null in its setter. In the example below, Name is typed as string so the getter never returns null, but the setter accepts null and coalesces it to a default value. Without the attribute, assigning null to Name would produce a warning even though the code handles it safely.
DisallowNull is the inverse: it prevents null in a setter even though the property type is nullable. Notes is typed as string? because the getter might return null before anything is assigned, but once you set it, you must provide an actual value. This expresses the contract “might not have a value yet, but you can’t set it to nothing.”
public class Person
{
private string name = "Unknown";
[AllowNull]
public string Name
{
get => name;
set => name = value ?? "Unknown";
}
private string? notes;
[DisallowNull]
public string? Notes
{
get => notes;
set => notes = value ?? throw new ArgumentNullException(nameof(value));
}
}
NotNullIfNotNull
With ?, a return type is either nullable or not. NotNullIfNotNull expresses something ? can’t: the return value is non-null whenever a specific parameter is non-null. This is common in transformation methods that pass through null as-is but always return a value when given one. Without this attribute, the return type must be string? to cover the null-input case, which forces callers who pass a non-null value to do unnecessary null checks on a result that can’t actually be null.
[return: NotNullIfNotNull(nameof(path))]
public string? NormalizePath(string? path)
{
return path?.Replace("\\", "/");
}
// Usage
string? nullable = GetPath();
string? result1 = NormalizePath(nullable); // Result is nullable
string nonNull = "/some/path";
string result2 = NormalizePath(nonNull); // Result is non-nullable
Nullable Value Types vs Nullable Reference Types
Nullable Value Types (pre-C# 8.0)
- Uses
Nullable<T>wrapper struct - Example:
int? nullableInt = null; - Provides
HasValueandValueproperties - Runtime overhead: extra struct wrapping
Nullable Reference Types (C# 8.0+)
- Compile-time annotation only
- Example:
string? nullableString = null; - No runtime overhead or wrapper type
- At runtime, still just the reference type (or null)
#nullable enable
// Nullable value type (struct) - always been in C#
int? nullableInt = null;
DateTime? nullableDate = null;
// These are Nullable<T> - a struct wrapper
int? a = 5;
bool hasValue = a.HasValue; // true
int value = a.Value; // 5
int valueOrDefault = a.GetValueOrDefault(); // 5
// Nullable reference type - C# 8.0+
string? nullableString = null;
// Not Nullable<T> - just annotated reference
// At runtime, still just a string (or null)
Common Patterns
Null Object Pattern
public interface ILogger
{
void Log(string message);
}
public class NullLogger : ILogger
{
public static readonly ILogger Instance = new NullLogger();
private NullLogger() { }
public void Log(string message) { /* Do nothing */ }
}
public class Service
{
private readonly ILogger logger;
// Never null - use NullLogger instead
public Service(ILogger? logger = null)
{
this.logger = logger ?? NullLogger.Instance;
}
public void DoWork()
{
logger.Log("Working"); // Always safe
}
}
Optional Return Values
// Clear intent: might not find anything
public Customer? FindCustomer(int id)
{
return customers.FirstOrDefault(c => c.Id == id);
}
// Usage forces handling the null case
var customer = FindCustomer(123);
if (customer is not null)
{
ProcessCustomer(customer);
}
// Or with null-coalescing
var customer = FindCustomer(123) ?? CreateGuestCustomer();
Constructor Initialization
public class Order
{
// Non-nullable - must be set
public string OrderNumber { get; }
public Customer Customer { get; }
// Nullable - optional
public string? Notes { get; set; }
public DateTime? ShippedDate { get; set; }
public Order(string orderNumber, Customer customer)
{
OrderNumber = orderNumber ?? throw new ArgumentNullException(nameof(orderNumber));
Customer = customer ?? throw new ArgumentNullException(nameof(customer));
}
}
Working with Legacy Code
// When calling code without nullable annotations
public string? GetValueFromLegacy()
{
// Legacy method might return null but isn't annotated
string result = LegacyLibrary.GetValue();
// Treat as potentially null
return result;
}
// Or when you know it's safe
public string GetValueFromLegacySafe()
{
// You've verified this never returns null
return LegacyLibrary.GetValue()!;
}
Migration Strategy
Gradual Adoption
- Enable annotations only first
<Nullable>annotations</Nullable>Add
?to intentionally nullable types without getting warnings. -
Enable warnings on new code Use
#nullable enablein new files. - Enable project-wide
<Nullable>enable</Nullable>Fix warnings incrementally.
Common Migration Fixes
// Before: warning on uninitialized non-nullable
public string Name { get; set; } // Warning
// Fix 1: Make nullable if truly optional
public string? Name { get; set; }
// Fix 2: Add default value
public string Name { get; set; } = "";
// Fix 3: Use required (C# 11)
public required string Name { get; set; }
// Fix 4: Initialize in constructor
public string Name { get; }
public Customer(string name) => Name = name;
Key Takeaways
Enable NRT for new projects: Start with <Nullable>enable</Nullable> for null safety from the beginning.
Use ? intentionally: Mark types nullable only when null is a valid state, not as a way to silence warnings.
Avoid overusing !: The null-forgiving operator should be rare. If you need it often, reconsider your design.
Prefer guards to !: Null checks are self-documenting and catch bugs; ! hides them.
Use attributes for complex flows: When the compiler can’t track nullability through method calls, attributes like NotNull and MemberNotNull help.
Migrate gradually: Enable annotations first, then warnings, fixing issues incrementally rather than all at once.
Found this guide helpful? Share it with your team:
Share on LinkedIn