Learnixo

.NET & C# Development · Lesson 7 of 229

Working with Null in C# — Nullable Reference Types

Working with Null in C# — Nullable Reference Types

NullReferenceException is the most common exception in C# codebases. C# 8 introduced nullable reference types (NRT) to make null-safety a compile-time concern rather than a runtime one.


Enabling Nullable Reference Types

Add to your .csproj:

XML
<PropertyGroup>
  <Nullable>enable</Nullable>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>  <!-- optional but recommended -->
</PropertyGroup>

Or per-file with a pragma:

C#
#nullable enable   // turn on for this file
#nullable disable  // turn off (legacy code)

The Two Null Worlds

C#
// Before NRT (nullable context disabled) — everything was implicitly nullable
string name = null;  // no warning — dangerous

// With NRT enabled:
string  name1 = null;   // WARNING: cannot assign null to non-nullable string
string? name2 = null;   // OK — explicitly nullable

// Value types have always had this distinction:
int  x = null;   // compile error — int cannot be null
int? y = null;   // OK — Nullable<int>

Null-State Analysis

The compiler tracks null-state: maybe-null vs not-null.

C#
string? GetName(bool flag)
{
    if (flag) return "Alice";
    return null;
}

string? name = GetName(true);

// name is "maybe-null" here — compiler warns on dereference
Console.WriteLine(name.Length);   // WARNING: dereference of possibly null reference

// After a null check, state becomes "not-null"
if (name != null)
    Console.WriteLine(name.Length);   // OK

// Pattern matching also narrows the type
if (name is string s)
    Console.WriteLine(s.Length);   // OK — s is not null

Null-Handling Operators

C#
string? input = null;

// Null-coalescing: return right side if left is null
string result = input ?? "default";   // "default"

// Null-coalescing assignment: assign only if null
input ??= "assigned";   // input is now "assigned"

// Null-conditional: short-circuit chain on null
string? upper = input?.ToUpper()?.Trim();   // null if input is null

// Null-conditional with indexer
List<string>? list = null;
string? first = list?[0];   // null instead of ArgumentNullException

// Null-forgiving operator ! (use sparingly — suppresses warning)
string definitelyNotNull = input!;   // tells compiler "trust me"
// Only use when you have guarantees the compiler can't see

Annotating Methods and Properties

C#
public class UserService
{
    private readonly Dictionary<int, string> _users = new();

    // Return type is string? — caller must handle null
    public string? GetName(int id)
        => _users.TryGetValue(id, out var name) ? name : null;

    // [NotNull] attribute: output parameter guaranteed not null when returns true
    public bool TryGetName(int id, [System.Diagnostics.CodeAnalysis.NotNull] out string? name)
    {
        name = _users.GetValueOrDefault(id);
        return name != null;
    }
}

var service = new UserService();
string? name = service.GetName(1);

// Force the caller to handle null:
string display = name ?? "Unknown";

// Or guard with exception:
if (name is null)
    throw new InvalidOperationException("User not found");

Console.WriteLine(name.Length);   // now safe

Required Properties and Constructors (C# 11+)

C#
public class Order
{
    // required — must be set during object initializer
    public required string Reference { get; init; }
    public required int CustomerId { get; init; }
    public string? Notes { get; init; }   // explicitly optional
}

// Compiler error if you miss a required property:
var order = new Order
{
    Reference  = "ORD-001",
    CustomerId = 42,
    // Notes is optional — omitting is fine
};

Null Object Pattern

Replace null checks with a safe "do nothing" object:

C#
public interface INotificationService
{
    void Send(string message);
}

public class EmailNotificationService : INotificationService
{
    public void Send(string message) => Console.WriteLine($"Email: {message}");
}

// Null object — safe default
public class NullNotificationService : INotificationService
{
    public void Send(string message) { /* intentionally empty */ }
}

// Consumer never checks for null
public class OrderProcessor(INotificationService notifications)
{
    public void Process(Order order)
    {
        // Do work...
        notifications.Send($"Order {order.Reference} processed");
    }
}

// Inject real or null object — no null checks inside OrderProcessor
var processor = new OrderProcessor(new NullNotificationService());

Common Pitfalls

C#
// PITFALL 1: Null-forgiving operator hiding a real bug
var name = GetMaybeName()!;   // hides potential null — exception at runtime

// BETTER: Handle null explicitly
var name = GetMaybeName() ?? throw new InvalidOperationException("Name required");

// PITFALL 2: string.IsNullOrEmpty vs string.IsNullOrWhiteSpace
string? s = "   ";
if (string.IsNullOrEmpty(s))     // false — not empty, just whitespace
    throw new ArgumentException();
if (string.IsNullOrWhiteSpace(s)) // true — catches whitespace too
    throw new ArgumentException();

// PITFALL 3: Mixing nullable and non-nullable in collections
List<string?> mixed = new() { "a", null, "b" };
foreach (string? item in mixed)
{
    // Must handle null per item
    Console.WriteLine(item?.ToUpper() ?? "(null)");
}

Interview Answer

"Nullable reference types (NRT), enabled via <Nullable>enable</Nullable>, split C# reference types into two categories: string (non-nullable — compiler warns if null is assigned) and string? (nullable — caller must handle null). The compiler performs null-state analysis: after a null check or pattern match, the state narrows to not-null and dereferences are safe. Key operators: ?? (null-coalescing), ??= (null-coalescing assignment), ?. (null-conditional). The null-forgiving operator ! suppresses warnings — use only when you have guarantees the compiler can't see. In practice: enable NRT project-wide, use required properties for mandatory fields, and prefer the null object pattern over pervasive null checks in service code."