C# Nullable Reference Types

📖 7 min read

The Null Problem

Null reference exceptions are one of the most common runtime errors.

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

<!-- 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

MemberNotNull

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

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

// Return value might be null even when T is non-nullable
[return: MaybeNull]
public T Find<T>(string key) where T : class
{
    return cache.ContainsKey(key) ? (T)cache[key] : null;
}

// Parameter is not null when method returns true
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

public class Person
{
    private string name = "Unknown";

    [AllowNull] // Allow null in setter even though property is non-nullable
    public string Name
    {
        get => name;
        set => name = value ?? "Unknown";
    }

    private string? notes;

    [DisallowNull] // Don't allow null in setter even though property is nullable
    public string? Notes
    {
        get => notes;
        set => notes = value ?? throw new ArgumentNullException(nameof(value));
    }
}

NotNullIfNotNull

// Return is not null if input is not 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 HasValue and Value properties
  • 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

  1. Enable annotations only first
    <Nullable>annotations</Nullable>
    

    Add ? to intentionally nullable types without getting warnings.

  2. Enable warnings on new code Use #nullable enable in new files.

  3. 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;

Version History

Feature Version Significance
Nullable reference types C# 8.0 Compile-time null safety
Null parameter checking (!) C# 8.0 Suppress null warnings
MemberNotNull attribute C# 9.0 Flow analysis for members
Required members C# 11 Ensure initialization

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