Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 085 / 100

Nullable reference types: a hands-on guide for engineers

Let the compiler find your NullReferenceExceptions.

Expona Admin6 min read

The NullReferenceException that haunts production

After a decade of building systems that move money, track inventory, and route passengers in real time, we've learned that nullable reference types: a hands-on guide for engineers isn't optional nicety—it's a career-saving investment. The compiler can find your null bugs before your customers do, but only if you turn on the feature and treat its warnings seriously. This article walks you through enabling nullable reference types in every project, understanding the distinction between string and string?, and migrating legacy codebases without drowning in warnings.

Enable <Nullable>enable</Nullable> in every new project

Nullable reference types are opt-in by default in C# 8.0 and later. That means your new .csproj file needs explicit activation. Open your project file and add this to the first PropertyGroup:

csharp
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Once enabled, the compiler becomes your first line of defense. It won't prevent runtime null references entirely—you still need to write defensive code—but it will flag suspicious assignments, method returns, and parameter passes at compile time.

For a real-world scenario: we were building a payment processing service that accepted order data from an event stream. Without nullable enabled, a developer could write code that passes a potentially null customerId to a repository query without any warning. With nullable enabled, that code won't compile until you either confirm the value is safe or handle the null case explicitly. That single toggle prevented a production incident where orders were silently attributed to the wrong customer.

Start with enable for all new projects. If you're working in a legacy codebase, we'll cover the gradual migration strategy later.

string means never null; string? means maybe

This is the semantic shift that takes the most getting used to. In pre-nullable C#, string was technically nullable—it could be null at runtime. Now, with nullable enabled, string means the compiler trusts you to ensure that value is never null. string? (the nullable variant) means it could be null, and the compiler demands you check it.

csharp
// Nullable enabled

// This compiles without warning: name is never null.
public string GetFullName(string firstName, string lastName)
{
    return $"{firstName} {lastName}";
}

// This requires the caller to handle potential null.
public string? FindUserById(int userId)
{
    var user = _userRepository.GetUser(userId);
    return user?.Name; // Could be null if user not found
}

// This does NOT compile: you cannot assign a nullable string to non-nullable.
string userName = GetUserInput(); // warning: GetUserInput returns string?
userName = null; // compile error

// This compiles: explicitly acknowledging null is possible.
string? userNameNullable = GetUserInput();
if (userNameNullable != null)
{
    Console.WriteLine($"Hello, {userNameNullable}");
}

The impact becomes apparent in larger systems. In a travel platform we worked on, booking details flowed through dozens of services. With nullable enabled, each service made a clear contract: "departure city is always set" (non-nullable) versus "return date may not exist for one-way trips" (nullable). Downstream code could depend on these contracts, eliminating defensive null-checks for fields that truly couldn't be null by design.

The rule is simple: if a reference type (class, string, record) could logically be null, mark it ?. If it cannot, don't.

Warnings as errors for nullable in CI

Warnings are a team discipline problem. Without enforcement, developers skip them when deadlines tighten. Convert nullable warnings to errors in CI so that no pull request bypasses the check.

csharp
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <WarningLevel>latest</WarningLevel>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <WarningsNotAsErrors></WarningsNotAsErrors>
  </PropertyGroup>
</Project>

If you want to be more surgical, treat only nullable-related warnings as errors:

csharp
<PropertyGroup>
  <Nullable>enable</Nullable>
  <WarningsNotAsErrors>CS0618;CS0619</WarningsNotAsErrors>
  <NoWarn>CS0618;CS0619</NoWarn>
</PropertyGroup>

In practice, we run this for all greenfield projects and incrementally enable it for legacy code as we refactor. The immediate effect is that code reviews catch null-safety issues instantly—no human reviewer has to spot a missing guard clause. Your CI pipeline enforces the contract automatically.

For a CRM system we maintain, this CI gate prevented a manager from merging a feature that accessed customer.PhoneNumber without checking whether PhoneNumber was nullable. The validation happened in seconds, without a code review back-and-forth. That saved us from a week-later support ticket when an imported batch of customers lacked phone numbers.

Migrate legacy projects file by file

Enabling nullable on a 50,000-line codebase at once produces thousands of warnings and demoralizes the team. Instead, enable it per file using a file-scoped directive.

csharp
// UserRepository.cs
#nullable enable

namespace MyApp.Data;

public class UserRepository
{
    public User? GetUserById(int id)
    {
        return _context.Users.FirstOrDefault(u => u.Id == id);
    }
    
    public string GetUserEmail(int id)
    {
        var user = GetUserById(id);
        // Compile error: user could be null, cannot access Email without null-check
        // return user.Email; // Won't compile
        
        return user?.Email ?? throw new InvalidOperationException("User not found");
    }
}

#nullable restore

Start with high-value, low-risk files. Data repositories, domain models, and API contract classes are good candidates because they have clear null semantics. Avoid enabling it in large procedural utility classes first.

A typical migration strategy for a legacy e-commerce platform looked like this:

  1. Enable nullable on domain entities and value objects (week 1).
  2. Enable on repository and data access layers (week 2).
  3. Enable on service/business logic layers (weeks 3–4).
  4. Enable on controllers and HTTP boundaries (week 5).
  5. Tackle utility and helper classes last (weeks 6–8).

Each stage builds on the previous one. You gain confidence in the pattern, and later files benefit from the null-safe contracts of earlier files. By the end, nullable is enforced across the entire codebase.

If you hit a wall with an external library that doesn't respect nullable annotations, use the null-forgiving operator sparingly:

csharp
// LegacyLib returns untyped object
var result = LegacyLib.GetData();
var name = result.Name!; // I know this isn't null, trust me (use rarely)

The ! operator tells the compiler "I've checked this, it's safe." Use it as an exception, not a pattern. If you're sprinkling ! throughout a file, that file needs deeper refactoring.

Common mistakes

Mistake 1: Enabling nullable but ignoring warnings. If warnings aren't errors in CI, they accumulate like technical debt. People skip them, and the safety guarantee erodes. Make them errors; that's the only enforcement mechanism that works at scale.

Mistake 2: Over-annotating everything with ?. If every parameter and return type is nullable, you've provided no information to the compiler or future readers. A function that genuinely requires a non-null name should declare it string name, not string? name. Reserve ? for values that can actually be absent.

Mistake 3: Confusing nullable value types with nullable reference types. Nullable value types use int?, bool?, etc., and have been in C# since generics. Nullable reference types are the newer feature for string?, User?, and so on. They're related concepts but different tools. Don't mix them up in code reviews.

Mistake 4: Not thinking about API boundaries. If your public API accepts string email (non-nullable), a caller passing null will get a runtime error, not a compile error. Document and enforce API contracts. If a parameter can be null, make it string? and handle it in the method body.

Key takeaways

  • Add <Nullable>enable</Nullable> to every new .csproj file. There's no reason not to.
  • string means never null; string? means it could be. This is the semantic contract the compiler enforces.
  • Treat nullable warnings as CI errors so your team can't ship null-safety bugs accidentally.
  • Migrate legacy codebases file by file, starting with domain entities and data access layers. Avoid large rewrites; let the compiler guide incremental improvements.
  • Use the null-forgiving operator (!) only for edge cases, not as a routine workaround.
  • Nullable reference types don't prevent all runtime null references, but they eliminate most of the careless ones. Pair them with defensive coding in critical paths.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles