Understanding Nullable Reference Types
Null reference exceptions have caused countless bugs since C# was created. Starting with C# 8, nullable reference types help you catch potential null errors at compile time instead of runtime. This feature changes how you think about null in your code.
Before this feature, all reference types could be null without any compiler warnings. Now, you explicitly mark which references can be null using the ? annotation. The compiler analyzes your code and warns you when you might access null values unsafely.
You'll learn how to enable nullable reference types, annotate your code correctly, handle nullable values safely, and migrate existing projects to use this feature effectively.
Enabling Nullable Reference Types
You control nullable reference types through project settings or file-level directives. New .NET 6+ projects enable them by default, but older projects need manual configuration.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<!-- Enable for entire project -->
<Nullable>enable</Nullable>
<!-- Or use warnings mode to get alerts without errors -->
<!-- <Nullable>warnings</Nullable> -->
<!-- Treat nullable warnings as errors -->
<!-- <WarningsAsErrors>CS8600;CS8602;CS8603</WarningsAsErrors> -->
</PropertyGroup>
</Project>
// Enable nullable reference types for this file
#nullable enable
public class Customer
{
public string Name { get; set; } // Non-nullable
public string? Email { get; set; } // Nullable
}
// Disable for legacy code section
#nullable disable
public class LegacyCode
{
public string OldProperty { get; set; } // No warnings
}
// Restore previous setting
#nullable restore
Start with warnings mode when migrating existing projects. This lets you see potential issues without breaking your build. Fix warnings gradually, then switch to enable mode.
Using Nullable Annotations
The ? annotation marks reference types as nullable. Without it, the compiler expects your references to never be null. This explicit marking makes your intent clear and helps catch bugs early.
#nullable enable
public class User
{
// Required property - cannot be null
public string Username { get; set; }
// Optional property - can be null
public string? MiddleName { get; set; }
// Required with default value
public string Email { get; set; } = string.Empty;
// Constructor ensures non-null values
public User(string username)
{
Username = username;
}
// Method with nullable parameter
public void UpdateEmail(string? newEmail)
{
if (newEmail != null)
{
Email = newEmail;
}
}
// Method returning nullable
public string? GetMiddleName()
{
return MiddleName;
}
// Non-nullable return type - must return a value
public string GetDisplayName()
{
return MiddleName != null
? $"{Username} ({MiddleName})"
: Username;
}
}
Mark parameters and return types as nullable when they can legitimately be null. This documents your API's contract and helps callers handle nulls correctly.
Safe Null Checking Patterns
The compiler tracks nullable state through your code. When you check for null, it knows the value is safe to use. Several patterns help you work with nullable types safely.
public class OrderService
{
// Pattern 1: Traditional null check
public void ProcessOrder(Order? order)
{
if (order == null)
{
throw new ArgumentNullException(nameof(order));
}
// Compiler knows order is not null here
Console.WriteLine(order.Id);
}
// Pattern 2: Null-conditional operator
public string? GetCustomerName(Order? order)
{
return order?.Customer?.Name;
}
// Pattern 3: Null-coalescing operator
public string GetOrderStatus(Order? order)
{
return order?.Status ?? "Unknown";
}
// Pattern 4: Pattern matching
public void LogOrder(Order? order)
{
if (order is { Status: "Pending" })
{
Console.WriteLine($"Pending order: {order.Id}");
}
}
// Pattern 5: Early return
public decimal CalculateTotal(Order? order)
{
if (order == null)
return 0;
return order.Items.Sum(i => i.Price);
}
// Pattern 6: ArgumentNullException.ThrowIfNull (C# 11+)
public void ValidateOrder(Order? order)
{
ArgumentNullException.ThrowIfNull(order);
// order is guaranteed non-null after this point
ProcessOrderItems(order.Items);
}
}
Using the Null-Forgiving Operator
Sometimes you know a value isn't null, but the compiler can't figure it out. The null-forgiving operator (!) tells the compiler to trust you. Use it carefully.
public class ProductService
{
private Dictionary<int, Product> _cache = new();
// Good use: After validation
public Product GetProduct(int id)
{
if (!_cache.ContainsKey(id))
{
_cache[id] = LoadFromDatabase(id);
}
// We know it's there, but compiler doesn't
return _cache[id]!;
}
// Good use: With TryGetValue
public string GetProductName(int id)
{
if (_cache.TryGetValue(id, out var product))
{
return product.Name; // No ! needed here
}
return "Unknown";
}
// Bad use: Hiding potential bugs
public void BadExample(string? input)
{
// Don't do this without validation!
string value = input!;
Console.WriteLine(value.Length); // Can still throw NullReferenceException
}
// Better: Validate first
public void GoodExample(string? input)
{
if (string.IsNullOrEmpty(input))
throw new ArgumentException("Input required", nameof(input));
// Now it's safe
Console.WriteLine(input.Length);
}
private Product LoadFromDatabase(int id)
{
return new Product { Id = id, Name = "Sample" };
}
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
Only use ! when you have information the compiler doesn't. Common scenarios include after dictionary lookups, with lazy initialization, or when working with legacy code that doesn't have nullable annotations.
Nullable Reference Types with Generics
Generic type parameters need special handling with nullable reference types. You can constrain whether generic types accept nulls using where clauses.
// Generic with class constraint - allows null
public class Repository<T> where T : class
{
private List<T?> _items = new();
public void Add(T? item)
{
_items.Add(item);
}
public T? Find(int id)
{
return _items.FirstOrDefault();
}
}
// Non-nullable constraint - T cannot be null
public class NonNullableRepository<T> where T : class
{
private List<T> _items = new();
public void Add(T item)
{
ArgumentNullException.ThrowIfNull(item);
_items.Add(item);
}
public T? Find(int id)
{
return _items.FirstOrDefault(); // Can return null
}
}
// Nullable annotation on generic parameter
public class Result<T>
{
public T? Value { get; }
public string? Error { get; }
public bool IsSuccess => Error == null;
private Result(T? value, string? error)
{
Value = value;
Error = error;
}
public static Result<T> Success(T value) =>
new Result<T>(value, null);
public static Result<T> Failure(string error) =>
new Result<T>(default, error);
}
Best Practices for Nullable Reference Types
Follow these guidelines to get the most benefit from nullable reference types:
Make non-nullability the default: Design your APIs so most references are non-nullable. Only mark types as nullable when they genuinely need to accept null. This makes your code's intent clear and reduces null checks.
Initialize non-nullable properties: Assign default values or use required properties to ensure non-nullable references always have values. Constructors should set all non-nullable properties.
Use nullable annotations consistently: Apply nullable annotations throughout your codebase. Mixed usage confuses developers and reduces the feature's effectiveness. If you enable it, commit to using it properly.
Validate at boundaries: Check for null at API boundaries like controller actions and public methods. Once validated, you can safely pass non-nullable values to internal methods.
Avoid excessive null-forgiving operators: Too many ! operators suggest problems with your design. If you find yourself using it frequently, reconsider your approach or add better validation.