Working with Nullable Reference Types in Modern C#

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.

YourProject.csproj - Project 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>
Program.cs - File-Level Control
// 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.

User.cs - Nullable Annotations
#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.

NullSafety.cs - Checking Patterns
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.

NullForgiving.cs - When to Use !
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.

GenericNullable.cs - Generic Constraints
// 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.

Frequently Asked Questions (FAQ)

What's the difference between nullable value types and nullable reference types?

Nullable value types (int?, bool?) have existed since C# 2.0 and allow value types to hold null. Nullable reference types are a C# 8 feature that adds compile-time null checking for reference types. They use the same ? syntax but work differently—reference types are already nullable at runtime.

How do I enable nullable reference types in my project?

Add Nullable enable to your .csproj file inside a PropertyGroup. You can enable it for the entire project or use #nullable enable directives in specific files. New projects in .NET 6 and later have it enabled by default. Warnings appear when null safety rules are violated.

What does the null-forgiving operator (!) do?

The ! operator tells the compiler you know a value isn't null, even if the compiler thinks it might be. Use it sparingly when you have knowledge the compiler doesn't, like after validation. Overusing it defeats the purpose of nullable reference types and can hide potential bugs.

Can I use nullable reference types with older code?

Yes, you can gradually adopt nullable reference types. Enable them file-by-file or suppress warnings initially. The feature is compile-time only and doesn't affect runtime behavior. Libraries without nullable annotations will show warnings, but you can use #nullable disable in those areas temporarily.

Do nullable reference types prevent all null reference exceptions?

No, they're a compile-time safety feature, not a runtime guarantee. You can still get null reference exceptions through reflection, incorrect use of the null-forgiving operator, or when interacting with code that doesn't use nullable annotations. They significantly reduce null errors but don't eliminate them completely.

Back to Articles