Nullable Reference Types — Eliminating Null Reference Exceptions

Nullable reference types (NRT), enabled with <Nullable>enable</Nullable> in the .csproj, make the C# type system aware of the difference between “this string can be null” (string?) and “this string is never null” (string). The compiler uses flow analysis to warn you when you dereference a potentially-null reference without checking, and when you pass a nullable value to a parameter that requires non-null. Enabling NRT from the start of a project, and correctly annotating all APIs, eliminates the NullReferenceException as a class of runtime bugs — they become compile-time warnings instead.

Enabling and Using Nullable Reference Types

// ── Enable in .csproj (project-wide) ──────────────────────────────────────
// <Nullable>enable</Nullable>

// ── Nullable annotations ───────────────────────────────────────────────────
string  nonNullable = "always has a value";   // warning if assigned null
string? nullable    = null;                    // can be null — must check before use

// ── Compiler flow analysis — tracks null state ────────────────────────────
string? value = GetValueOrNull();

// value here is "maybe null"
Console.WriteLine(value.Length);   // warning: possible dereference of null

if (value is not null)
{
    // value here is "not null" — compiler promotes to string
    Console.WriteLine(value.Length);   // ✓ no warning
}

// ── Null forgiving operator ! — suppress the warning (use sparingly) ──────
string definitelyNotNull = GetValueOrNull()!;   // ! = trust me, it's not null
// Use only when YOU know it cannot be null but the compiler does not
// (e.g., immediately after a Dictionary lookup you verified with ContainsKey)
Note: The NRT system is purely compile-time — at runtime, reference types are still nullable as they have always been. string? and string compile to the same IL; the difference is only in compiler warnings and attributes embedded for tooling. This means you cannot rely on NRT alone to prevent null at runtime — you still need null-checks for data coming from external sources (databases, HTTP request bodies, configuration). NRT catches programmer errors at compile time; it does not change the runtime behaviour.
Tip: When transitioning an existing project to nullable-enabled, use #nullable enable at the top of individual files to migrate incrementally. You can also use #nullable disable in specific files where fixing nullable warnings is deferred. ASP.NET Core projects created with .NET 6+ have nullable enabled by default. For EF Core entities, the convention-based approach is to use string? for optional columns and string (with a backing field initialised to string.Empty) for required columns.
Warning: The null-forgiving operator ! is a code smell if used frequently. Each ! is a statement “I know this is not null, trust me” — if you are wrong, you get a NullReferenceException at runtime with no compile-time safety. Use ! sparingly and only in cases where you have just verified null is impossible but the compiler’s flow analysis cannot see it (after a non-nullable check in an interface method, after Assert.NotNull() in tests, or after a known-non-null factory method). A codebase with many ! operators has likely not been annotated correctly.

Annotating APIs — Nullability Attributes

using System.Diagnostics.CodeAnalysis;

public class UserService
{
    // [NotNullWhen(true)] — return value is not null when method returns true
    public bool TryGetUser(int id, [NotNullWhen(true)] out User? user)
    {
        user = _cache.Get<User>(id);
        return user is not null;
    }

    // [MaybeNull] — return value may be null even though type is non-nullable
    [return: MaybeNull]
    public T GetOrDefault<T>(string key) where T : class
        => _cache.Get<T>(key);   // may return null from cache

    // [NotNull] — output is guaranteed non-null even if declared nullable
    public void EnsureCreated([NotNull] ref User? user)
    {
        user ??= new User { Name = "Guest" };
        // after this method, user is guaranteed not null
    }
}

// Caller benefit — compiler understands the contract
if (service.TryGetUser(42, out var user))
{
    // user is promoted to User (non-nullable) here
    Console.WriteLine(user.Name);   // ✓ no nullable warning
}

Nullable with EF Core Entities

public class Post
{
    public int    Id        { get; set; }           // required — non-nullable
    public string Title     { get; set; } = string.Empty; // required column
    public string? Subtitle { get; set; }           // optional column — nullable
    public string AuthorId  { get; set; } = string.Empty; // required FK

    // Navigation properties — non-null after Include()
    public User   Author    { get; set; } = null!;  // EF Core initialises this
    public ICollection<Tag> Tags { get; set; } = new List<Tag>();
}

Common Mistakes

Mistake 1 — Overusing the null-forgiving operator ! to silence warnings

❌ Wrong — suppressing all warnings defeats the purpose of nullable analysis:

Console.WriteLine(value!.Length);   // silenced warning — null crash at runtime!

✅ Correct — fix the root cause: check for null, or fix the API to return non-nullable.

Mistake 2 — Not initialising navigation properties (EF Core nullable warning)

❌ Wrong — compiler warns that Author is not initialised:

public User Author { get; set; }   // warning: non-nullable, not initialised

✅ Correct — use the null-forgiving initialiser pattern for EF Core navigation properties:

public User Author { get; set; } = null!;   // EF Core sets this; null! silences warning

🧠 Test Yourself

With nullable enabled, what is the difference between string name and string? name in terms of runtime behaviour and compile-time behaviour?