Chapter 3: Null safety

Modified

May 3, 2026

Tony Hoare—the inventor of the null reference—once called it “the Billion Dollar Mistake.” A decision that seemed harmless at the time ended up causing many crashes and security issues, and it made some bugs painfully hard to diagnose. Programming can feel like walking through a minefield: you never know which line will trigger a NullReferenceException because of an unexpected null.

Since its earliest versions, C# has steadily evolved its approach to handling nulls: C# 2.0 brought nullable value types (T?), later releases added null-related operators (??, ?.), and C# 8 introduced Nullable Reference Types (NRT). Each step makes nullability more explicit, more traceable, and easier for the compiler to check.

This chapter walks through modern C# null safety so you can reduce NullReferenceException incidents and move more null handling from “debug it later” to “catch it earlier.”

3.1 Why do we need null safety?

In earlier versions of C#, reference-type variables could be null by default:

string name = null;  // Completely legal
int length = name.Length;  // Throws NullReferenceException at runtime

This leads to a few predictable problems:

  • High cost: NullReferenceException has been one of the most common causes of .NET application crashes for years.
  • Hard to catch early: The compiler can’t detect these issues at compile time; they only surface at runtime.
  • Hard to trace: In large projects, null can appear anywhere along a complex call chain.

Early approaches

Before Nullable<T>, handling “missing values” (such as database columns) was awkward. Developers often used magic numbers to represent “no data,” such as:

// Old-school approach
int age = -999999;  // Magic number meaning “no data”
if (age == -999999)
{
     /* Handle missing data */
}

DateTime birthday = DateTime.MinValue;  // Minimum value meaning “no date”
if (birthday == DateTime.MinValue) { /* ... */ }

This is error-prone and makes code harder to understand and maintain.

3.2 Nullable value types

C# 2.0 introduced nullable value types, implemented as the generic System.Nullable<T>, so value types can represent a “no value” state.

Think of Nullable<T> as a gift box:

  • It might contain a value (there is a value).
  • Or it might be empty (null).

Syntax: the concise T?

You can write Nullable<int>, but C# provides cleaner syntactic sugar:

Nullable<int> x;   // Verbose form
int? y;            // Concise form

In modern C# code, prefer the T? form.

Assigning and reading values

You can assign values normally, but you must be careful when reading the value:

int? age = null;  // Can be null
age = 25;         // Can also hold a value

// Option 1: use it directly
if (age == 25) { ... }

// Option 2: use the Value property
if (age.Value == 25) { ... }  // WARNING: throws if age is null!

Reminder: always check for null (or HasValue) before using .Value; otherwise, you’ll get an InvalidOperationException.

Under the hood: Nullable<T>

Understanding the internal structure helps explain its behavior. Nullable<T> is a small struct with two fields. Conceptually, it looks like this (simplified):

public struct Nullable<T> where T : struct
{
    private bool hasValue;
    private T value;

    public bool HasValue => hasValue;

    public T Value
    {
        get
        {
            if (!hasValue)
                 throw new InvalidOperationException("Nullable object must have a value.");
            return value;
        }
    }
}

There are three points to keep in mind:

  • int? uses a bit more memory than int because it must store an extra “has value” flag.
  • HasValue makes the intent “does this have a value?” explicit, so it often reads more clearly than != null.
  • Assigning null effectively sets hasValue = false.

Checking whether it has a value

This is one of the most common operations:

int? score = null;

// Option 1: compare to null
if (score != null) { Console.WriteLine(score); }

// Option 2: use HasValue (more explicit)
if (score.HasValue) { Console.WriteLine(score.Value); }

Both are fine. HasValue often reads a bit more clearly.

Converting to and from non-nullable types

Converting a value type to its nullable counterpart is implicit (and safe). The reverse operation requires an explicit cast because it introduces the risk of failure:

// Non-nullable -> nullable: implicit
int i = 10;
int? j = i;

// Nullable -> non-nullable: explicit
int? x = 10;
int y = x;        // ✗ Compile error
int z = (int)x;   // ✓ Explicit cast; throws at runtime if x is null

An explicit conversion tells the compiler: “I know what I’m doing”—and you accept the responsibility for the runtime risk.

Arithmetic with nullable values

In an arithmetic expression, if one operand is null, the result is null. In practice, this behavior is often called null propagation:

int? a = null;
int? b = 10;
int? c = a + b;  // c is null
int? d = a * b;  // d is also null

This matches intuition: if one of the operands is unknown (null), the overall result is naturally unknown as well.

In the C# language specification, this capability is implemented under the hood via lifted operators. A “lifted” operator means the compiler automatically expands an operator that normally works on a value type (like int) so it can also work with the corresponding nullable type (like int?).

Thanks to this mechanism, you can directly mix nullable and non-nullable operands:

int? m = 10;
int n = 5;
int? result = m * n;  // result = 50 (type is int?)

Even if one operand (n) is a regular non-nullable type, the overall result of the expression is still nullable. This preserves type consistency and saves you from writing tedious casts in everyday expressions.

Source code: DemoNullableValueType

Ask AI

Explain the results of the following C# code, focusing on null propagation. Then rewrite it to avoid unnecessary nullable conversions, and compare performance:

int? a = null;
int? b = 10;
int? c = 5;

// What are the results and why?
var result1 = a + b + c;
var result2 = (a ?? 0) + (b ?? 0) + (c ?? 0);
var result3 = a?.CompareTo(b) ?? 0;

Also: in data-processing pipelines, does frequent use of Nullable<T> have meaningful performance overhead?

3.3 Null operators: Making null handling concise

To keep code both concise and safe, C# provides several operators specifically designed for null handling:

  • Null-coalescing operator (??): Provides a fallback value and prevents nulls from flowing downstream.
  • Null-coalescing assignment (??=): Simplifies lazy initialization and “assign default if null” patterns.
  • Null-conditional operators (?. and ?[]): Safely access members or indexers even when the target might be null.
  • Null-conditional assignment (C# 14): Assigns only when the receiver is not null.

Null-coalescing operator (??)

The null-coalescing operator is ??. Its meaning is: “if the left operand is not null, use it; otherwise use the right operand.”

It’s essentially Plan B: if Plan A is null, fall back to Plan B.

Example:

int? x = null;
int y = x ?? 5;   // y becomes 5

In this example, the fallback value 5 is an int, so the result of the expression cannot be null. This is equivalent to:

int y = (x != null) ? x.Value : 5;

Practical examples:

int? score = null;
int finalScore = score ?? 0;  // finalScore = 0

string name = GetUserName() ?? "Guest";  // Use a fallback when the method returns null

Null-coalescing assignment operator (??=)

Starting with C# 8, you can use the null-coalescing assignment operator ??=. It means: “if the left operand is null, assign the right operand to it.”

Code like this:

if (fontName == null)
{
    fontName = "Times New Roman";  // Assign a default value
}

This can be reduced to a single line:

fontName ??= "Times New Roman";  // Assign only if fontName is null

Common use: lazy initialization

Example:

private List<string>? _cache;

public List<string> GetCache()
{
    _cache ??= LoadFromDatabase();  // Load only on the first call
    return _cache;
}

This means LoadFromDatabase() is called and its result assigned only when _cache is null. If _cache already has a value, we return it directly. This is a common pattern for lazy initialization.

Null-conditional operators (?. and ?[])

The null-conditional operators are ?. and ?[]. Use ?. to access a member (a property or method) only if the receiver isn’t null; otherwise, the entire expression evaluates to null. You use ?[] similarly for indexers.

Start with ?.. The following example calls obj.ToString() directly:

// ✗ A typical “no null defense” anti-pattern
string Convert(object obj)
{
    return obj.ToString();
}

If the caller passes null for obj, you’ll get a NullReferenceException. Rewrite it like this:

string? Convert(object? obj)
{
    return obj?.ToString();
}

Line 3 is equivalent to:

return (obj == null ? null : obj.ToString());

With indexers: ?[]

To access an indexer safely, use ?[]:

char? FirstChar(string? str)
{
    return str?[0];
}

This means: if str is null, return null; otherwise try to return the first character.

Important: ?[] only checks whether the receiver is null. It does not check whether the index is in range. So if str is an empty string "", str?[0] will still throw an IndexOutOfRangeException. If you want to treat both null and empty strings as “no character,” add a length check or use string.IsNullOrEmpty.

Chaining (short-circuit behavior)

Null-conditional chaining is also safe, for example:

string? Convert(object? obj)
{
    return obj?.ToString()?.ToUpper();
}

There are two points to notice:

  • If obj is null, the expression evaluates to null immediately, regardless of how many member accesses follow. This behavior is known as short-circuiting.
  • Here we also use ?. on the result of ToString(), because in projects with NRT enabled, if the receiver’s compile-time type is object, the compiler treats object.ToString() as possibly returning null. Writing it this way avoids unnecessary null warnings.

For multi-level member access, ?. lets you chain the null checks through each level. For example:

int? orderCount = customer?.Orders?.Count;

Even if customer or Orders is null, the program does not throw; it returns null.

Without ?., you have to expand each null check:

int? orderCount = customer == null ?
   null : (customer.Orders == null ? null : customer.Orders.Count);

Reminder: in these examples, the variables that receive the result are nullable (int?, char?). That’s required because the result can be null.

Combining operators

Null-coalescing and null-conditional operators are often used together:

string Convert(object? obj)
{
    return obj?.ToString() ?? "N/A";
}

If the caller passes null, this method returns “N/A”.

Note that the return type is string, not string?. The pattern of “accepting nullable inputs but guaranteeing a non-null result” is very practical: callers can use the return value without extra null checks.

Note: the compiler can only distinguish string from string? and provide null-safety warnings when NRT is enabled. See Section 3.4.

Other common combinations:

// If user is null, or Name is null, use a fallback
string displayName = user?.Name ?? "Anonymous";

// If config is null, or Settings is null, use a default value
int bufferSize = config?.Settings?.BufferSize ?? 128;

When you need a fallback for multi-level nullable values, this style keeps safe access and defaulting in the same expression.

Null-conditional assignment operator (C# 14)

C# 14 extends ?. and ?[] so they can appear on the left-hand side of an assignment. This gives you a concise way to express “assign only if the receiver isn’t null.”

Previously, you had to do a null check first:

// C# 13 and earlier
if (customer is not null)
{
    customer.Order = GetCurrentOrder();
}

Now you can write:

// C# 14: null-conditional assignment
customer?.Order = GetCurrentOrder();

This means: “Assign the result of GetCurrentOrder() to customer.Order only when customer is not null.”

Important detail: the expression on the right side is evaluated only if the left side is not null. If customer is null, GetCurrentOrder() is not called.

This also works with compound assignments:

// Works with += and other compound assignments
customer?.Points += 100;  // Adds points only if customer is not null

// ✗ The following does not compile:
counter?.Value--;         // Error: ++/-- can't be used with ?. assignment

Note: The -- and ++ operators (whether prefix or postfix) cannot be used with null-conditional assignment. This is because ++ and -- not only assign a new value but also produce a result. If customer is null, the language would have to define what the entire expression should return. To avoid that ambiguity, C# does not allow this syntax.

Source code: DemoNullOperators

3.4 Nullable reference types

The T? syntax we discussed earlier is primarily about value types, but in practice reference types are the main source of NullReferenceException. C# 8.0 introduced Nullable Reference Types (NRT) so the compiler can catch potential null-reference bugs at compile time. This is one of the most important safety features in modern C#.

Enabling the nullable context

For backward compatibility, older .NET projects often had NRT disabled by default. Starting with .NET 6, new project templates enable NRT by default, but whether it is enabled still depends on project settings. To enable or disable it explicitly, set the Nullable property in your .csproj:

<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

You can also enable it for a single file with a pragma directive:

#nullable enable

File-level control is useful when migrating a legacy codebase gradually instead of fixing everything at once.

Core concepts: ? and ! annotations

With NRT enabled, the semantics of reference types change fundamentally:

#nullable enable

string name1;   // Non-nullable
string? name2;  // Nullable

The ? explicitly tells the compiler “this variable may be null.” Without ?, the compiler treats it as “should not be null.”

Note

If NRT is not enabled, string still behaves like it did in older C#: null is allowed by default. In that state, even if you write string?, the compiler usually treats it as an annotation only, and you may even see warnings such as CS8632 telling you that nullable annotations are not currently enabled, rather than fully tracking and enforcing nullability under NRT rules. With NRT enabled, string is treated as non-nullable, and you use string? to explicitly represent “may be null.” This change encourages you to handle nulls intentionally.

Non-nullable reference types

For non-nullable declarations, the compiler treats them as safe to use under the current nullability contract and flow-analysis result:

// The compiler assumes GetUserName() won't return null
string name = GetUserName();

// ✓ Safe from the compiler's point of view; no warning
Console.WriteLine(name.Length);

Keep in mind that “safe” here means the compiler does not issue a nullability warning based on the information it currently has. It does not mean runtime nulls are impossible. If an external API, legacy code path, or third-party library violates its contract, you can still get a runtime failure.

Nullable reference types

For nullable declarations, the compiler warns you and requires a null check:

string? name = GetOptionalName();  // Explicit: may return null

Console.WriteLine(name.Length);  // Warning: possible null dereference

To eliminate the warning, check first:

if (name != null)
{
    // ✓ Safe; the compiler knows name is not null here
    Console.WriteLine(name.Length);
}

Compiler flow analysis

The compiler performs static flow analysis to track the null state of variables. It recognizes common guard patterns:

void Process(string? input)
{
    if (input == null) return;        // Early return

    // The compiler knows input is not null here
    Console.WriteLine(input.Length);  // No warning
}

It also understands is pattern checks:

void Process(string? input)
{
    if (input is null) return;

    // Here input is narrowed to string (non-nullable)
    Console.WriteLine(input.Length);
}

More complex logic is supported as well:

void Process(string? input)
{
    // The compiler understands boolean logic
    if (input is null || input.Length == 0) return;

    // Here input is guaranteed non-null and non-empty
    Console.WriteLine(input.ToUpper());
}

However, flow analysis has limits. It can’t “see through” code like this:

string? name = GetName();
ValidateName(name);  // The compiler can't know this method checks for null

Console.WriteLine(name.Length);  // Warning: name may be null

void ValidateName(string? n)
{
    if (n == null) throw new ArgumentNullException();
}

Flow analysis has an important limitation: without extra nullability contract information, it usually reasons only from the visible control flow and its built-in rules.

So even though ValidateName throws an exception on null, the compiler does not know that. When it checks the later name.Length access, it still cannot prove that name is non-null, so it reports a warning.

In cases like this, you can use the [NotNull] attribute to help the compiler understand:

using System.Diagnostics.CodeAnalysis;

void ValidateName([NotNull] string? n)
{
    if (n == null) throw new ArgumentNullException();
}

This tells the compiler that the method ensures the argument is not null, allowing it to reason correctly and suppress the warning.

Ask AI

When migrating a legacy project, I enabled Nullable Reference Types and ran into null warnings I can’t eliminate. For the following scenarios, should I use the ! operator, or is there a better refactoring approach?

  1. A private field is initialized in a private method called by the constructor, but the compiler can’t track it.
  2. A unit test framework initializes the test subject in a [SetUp] method.
  3. Dependencies are injected by an external DI container and marked as required.

When is null! reasonable, and when should I refactor instead?

Null-forgiving operator (!)

Sometimes the compiler can’t prove a value is non-null, even when you know it’s safe. In such cases, you can use the ! operator to tell the compiler: “I guarantee this isn’t null—don’t warn me.”

Example:

string? maybeNull = GetLegacyRequiredValue();
string definitelyNotNull = maybeNull!;  // An older API contract guarantees a value here

Console.WriteLine(definitelyNotNull.Length);  // No warning

The key point is that some legacy APIs or third-party libraries may expose a string? signature even though, under a specific contract or call path, you know the value is guaranteed to be non-null. That is the kind of situation where ! can be reasonable.

A word of caution: don’t use ! merely to silence warnings. Doing so pushes a risk the compiler could have flagged back into runtime behavior. Use ! only when you are genuinely certain.

Common scenarios where ! can be useful:

(1) Delayed initialization the compiler can’t track

public class MyClass
{
    private string _value = null!;  // I'm sure it will be initialized

    public MyClass()
    {
        Initialize();  // Sets _value
    }

    private void Initialize()
    {
        _value = "Initial value";
    }
}

While null! can be a reasonable stopgap in this kind of scenario, [MemberNotNull] is often a better way to tell the compiler that the field is guaranteed to be initialized here. For example, the previous sample can be rewritten like this:

using System.Diagnostics.CodeAnalysis;

public class MyClass
{
    private string _value;  // Use an attribute instead of ! to describe the initialization guarantee

    public MyClass()
    {
        Initialize();
    }

    public string Value => _value;

    [MemberNotNull(nameof(_value))]
    private void Initialize()
    {
        _value = "Initial value";
    }
}

Applying [MemberNotNull] to Initialize tells the compiler: if this method completes successfully, _value can be treated as initialized and no longer needs to be considered null.

Tip

Starting from C# 11, if a public property must be supplied by the caller during object creation, consider using the required modifier instead (see Chapter 4). However, this example shows a private field initialized inside the class’s construction flow, which is not the main use case for required.

(2) Framework-provided property injection (Blazor as an example)

public partial class MyComponent
{
    [Inject]  // Blazor injects this at runtime, but the compiler can't infer that
    public ILogger<MyComponent> Logger { get; set; } = null!;
}

Some frameworks support property injection, but the exact behavior depends on the framework. The example above uses a Blazor component: the framework sets the property value at runtime, but the compiler cannot infer that from the code alone, so = null!; is commonly used here.

In ordinary application services, constructor injection is still the preferred approach. In other words, you receive dependencies through the constructor, which usually avoids the need for this kind of null!.

(3) Test setup code

[SetUp]
public void Setup()
{
    // The legacy helper's signature is loose, but
    // this test scenario guarantees successful creation
    _testSubject = CreateTestSubject()!;
}

Note that a test framework mechanism such as [SetUp] usually guarantees only that the initialization method runs before the test. It does not guarantee that CreateTestSubject() itself returns a non-null value.

If CreateTestSubject is not supposed to return null in the first place, the better fix is usually to declare its return type as non-nullable. Using ! here makes sense mainly when you’re constrained by legacy code, test tooling, or an existing helper signature and you know that this particular call won’t return null.

Ask AI

Prompt: “Is it reasonable and appropriate to use the null-forgiving operator in the foreach statement in the following code? Why?”

var allTasks = Task.WhenAll(ThrowAsync("Error A"), ThrowAsync("Error B"));
try
{
    await allTasks;
}
catch
{
    // Inspect allTasks.Exception to retrieve all errors
    foreach (var innerEx in allTasks.Exception!.InnerExceptions)
    {
        Console.WriteLine($"Error: {innerEx.Message}");
    }
}

When you try this prompt, a well-reasoned AI response should confirm that, in this specific example, using ! is appropriate, and explain:

  • If await allTasks throws and execution enters the catch block, it usually means that an asynchronous operation failed, or that it was cancelled partway through.
  • However, in this example, ThrowAsync(...) is deliberately designed to throw, so the focus is on failure rather than cancellation.
  • Under that assumption, allTasks.Exception has a value, so using ! tells the compiler: “I know this value is not null here.”

Here is another prompt for practicing a related skill: writing correct null annotations for common patterns:

I’m maintaining a utility library and need correct null annotations for these common patterns:

  1. TryParse-style methods (the out value is non-null on success; not guaranteed on failure)
  2. Lazy-initialized properties (created on first access)
  3. Conditional initialization methods (set only under certain conditions)

Rewrite the signatures using attributes like [NotNullWhen], [MaybeNull], and [NotNull], and explain how those annotations help callers.

Real-world example: API design

When designing APIs, correct null annotations save callers from guessing. A quick glance at the method signature should tell callers what may be null:

#nullable enable

public class UserService
{
    // Return value is non-null
    public User GetUser(int id)
    {
        var user = FindUserInDatabase(id);
        return user ?? throw new UserNotFoundException(id);
    }

    // Return value may be null
    public User? FindUser(string email)
    {
        return FindUserByEmail(email);  // Might not exist
    }

    // Parameters are non-null
    public void UpdateName(User user, string name)
    {
        // Under the current nullable contract,
        // the compiler can treat user and name as non-null
        user.Name = name;
    }

    // Parameter may be null
    public void UpdateNickname(User user, string? nickname)
    {
        user.Nickname = nickname;  // nickname is allowed to be null
    }
}

Source code: DemoNullableReferenceType

Compatibility with legacy code

NRT is a compile-time feature; it doesn’t change runtime behavior. That means:

  • Existing code won’t stop running just because you turned on NRT.
  • Even with NRT warnings, the project can still build and run (unless you treat warnings as errors, covered later).
  • You can adopt NRT incrementally instead of fixing everything immediately.

Suggested approach:

  1. For new projects, use the default (project-level NRT enabled).
  2. For older projects, start by enabling #nullable enable in individual files and migrate step by step.

Separating annotation and warning contexts (advanced)

#nullable enable actually does two things:

  1. Annotation context: Treat reference types as non-nullable unless they’re annotated with ?.
  2. Warning context: Produce null-safety warnings.

You can control them separately. For example, enable only one of them in a file:

#nullable enable annotations    // Enable annotations only (no warnings)

Or:

#nullable enable warnings       // Enable warnings only (doesn't change annotations)

You can also enable both (equivalent to #nullable enable):

#nullable enable annotations
#nullable enable warnings

You can do the same at the project level:

<PropertyGroup>
    <Nullable>annotations</Nullable>  <!-- or warnings, or enable -->
</PropertyGroup>

Practical use: when migrating a large legacy codebase, you might start with annotation context only:

#nullable enable annotations

public class LegacyService
{
    // Explicitly annotate your public contract
    public User? FindUser(int id) { ... }
    public void UpdateUser(User user) { ... }  // user is non-null

    // Internal code may still need work, but you won't be flooded with warnings yet
}

This gives downstream consumers clear nullability contracts right away, while you fix internal warnings gradually.

Treating null warnings as errors

For new projects, it’s often a good idea to promote null warnings to errors:

<PropertyGroup>
  <Nullable>enable</Nullable>
  <WarningsAsErrors>CS8600;CS8602;CS8603</WarningsAsErrors>
</PropertyGroup>

Common null-related warning codes include:

  • CS8600: Converting a null literal or possible null value to a non-nullable type
  • CS8602: Dereference of a possibly null reference
  • CS8603: Possible null reference return

Ask AI

Fix all CS86xx warnings in this .NET project. The project was upgraded from an older .NET version and has never had NRT enabled. Please first judge the actual intent of the code: only mark a type as nullable (T?) when null is truly a valid value. If a value should not be null, prefer fixing the initialization flow, adding guard clauses, applying appropriate nullability attributes, or refactoring the code to eliminate the warning. Use ! only as a last resort when you have strong reasons to know the compiler cannot infer the guarantee.

After each .cs file change, pause. I will review, rebuild, and check whether the warning count decreases. Only continue when I tell you to.

3.5 Pattern matching and null

Pattern matching is a major C# feature; see Chapter 6 for a full discussion. This section focuses on using pattern matching for null checks.

is null and is not null (C# 9+)

Using pattern matching for null checks is often clearer and safer than == null:

// Traditional: can be fooled by operator overloads
if (user == null) { ... }

// Modern approach: not affected by overloaded ==
if (user is null) { ... }
if (user is not null) { ... }

Here, is null has been available since C# 7.0, while is not null was introduced in C# 9.0 together with the not pattern.

The is form is preferable because:

  • It reads clearly (almost like English)
  • It’s not affected by overloaded == (safer)

Switch expressions and null

This section uses switch expressions. If you’re not familiar with them, see Chapter 6 or learn from the example below.

Switch expressions make null handling flow naturally alongside other logic:

string GetDisplayName(User? user) => user switch
{
    null => "Guest",
    { IsVip: true } => $"VIP: {user.Name}",
    _ => user.Name
};

This integrates null handling into your branching logic, avoiding extra if checks and keeping the code compact.

Property patterns and null

Property patterns let you put null checks and property conditions in the same pattern:

if (customer is { Address.City: "Taipei", Age: > 18 })
{
    // customer is not null,
    // and Address is not null,
    // and City is Taipei; Age > 18
}

This handles multiple levels of null checking automatically, without the need to chain ?. manually. The compiler ensures the if block runs only when all intermediate objects are non-null.

Source code: DemoNullPatternMatching

Ask AI: compare null-checking styles

Use this prompt to ask an AI to compare readability, performance, and safety:

Here are three ways to write the same logic. Compare them in readability, performance, and null safety:

// 1. Traditional if/else
if (customer != null && customer.Orders != null && customer.Orders.Count > 0)
{
    ProcessVipOrder(customer);
}

// 2. Null-conditional operator + null-coalescing
if (customer?.Orders?.Count is > 0)
{
    ProcessVipOrder(customer);
}

// 3. Property pattern
if (customer is { Orders.Count: > 0 })
{
    ProcessVipOrder(customer);
}

In large-scale applications, do complex property patterns have meaningful performance costs?

3.6 Best practices

This section summarizes some best practices. These guidelines help you write safer, more maintainable code.

1. Prefer explicit nullability in your API

// 1. Guaranteed value (throw if not found)
public User GetUser(int id) { ... }

// 2. May be missing (return null if not found)
public User? FindUser(int id) { ... }

2. Check early to prevent null from spreading

// ✗ Null spreads through the entire method
public void ProcessOrder(Order? order)
{
    var items = order?.Items;  // items may be null
    var count = items?.Count;  // count may be null
    // ...now the whole method has to deal with null
}

// ✓ Guard clause / early return
public void ProcessOrder(Order? order)
{
    if (order is null) return;

    // Everything below can assume order is not null
    var items = order.Items;
    var count = items.Count;
}

This guard-clause pattern keeps the method body concise and makes the preconditions clear at a glance.

3. Use nullability attributes (advanced)

For cases that aren’t fully expressible through NRT annotations alone, you can use attributes to provide extra information, such as [NotNull], [MaybeNull], and [NotNullWhen]:

using System.Diagnostics.CodeAnalysis;

public bool TryGetUser(int id, [NotNullWhen(true)] out User? user)
{
    user = FindUser(id);
    return user != null;
}

// Usage
if (TryGetUser(123, out var user))
{
    Console.WriteLine(user.Name);  // The compiler knows user is not null here
}

With [NotNullWhen(true)], the compiler understands that when the method returns true, the out parameter is definitely not null.

4. Combine modern syntax thoughtfully

Combining the null-conditional operator, null-coalescing operator, and pattern matching can produce code that’s both compact and safe:

// ?. + ?? together
var name = user?.Profile?.DisplayName ?? user?.Name ?? "Unknown";
Console.WriteLine($"Hello, {name}!");

This line handles multiple levels of null checks and fallback logic. If it feels too dense, ask an AI to explain it; if it is inconvenient to debug, split it into steps:

// Try to get the display name first
string? nameFromProfile = user?.Profile?.DisplayName;

// Decide the final display name (Profile first, then Name, then Unknown)
var name = nameFromProfile ?? user?.Name ?? "Unknown";

AI collaboration: detect null risks

When maintaining legacy projects, you can ask an AI to surface potential null risks.

Prompt

Analyze this project’s code and identify places that might throw NullReferenceException. Suggest guard clauses using modern C# pattern matching or null-conditional operators, and recommend enabling Nullable Reference Types.

Summary

  • Nullable value types (T?): Let value types represent “no value.”
  • Null operators (??, ??=, ?., ?[]): Simplify null-handling logic and make code safer. C# 14 further extends this with null-conditional assignment.
  • Nullable Reference Types (C# 8+): Detect many null bugs at compile time.
  • Pattern matching: is null, is not null, and switch expressions make null checks clearer and safer.
  • Best practices: Annotate explicitly, guard early, and combine modern syntax carefully to keep NullReferenceException out of your code.

In the next chapter, we’ll look at immutability and how it supports safer, more maintainable designs.

Glossary

Term Description
Guard clause An early check at the start of a method to validate inputs and return/throw quickly.
Null A special value representing “no object” / “no reference.”
Null-coalescing assignment operator ??=; assigns only when the left-hand side is null.
Null-coalescing operator ??; provides a fallback value when the left-hand side is null.
Null-conditional operator ?. and ?[]; access members/indexers safely when the receiver might be null.
Null-forgiving operator !; tells the compiler “treat this as non-null,” suppressing nullability warnings.
Nullable reference type (NRT) C# 8+ feature that lets the compiler analyze and warn about possible null dereferences.
Nullable value type A value type that can represent no value via T? (Nullable<T>).
Null propagation A behavior where an expression becomes null when a null value participates in the computation.
NullReferenceException The runtime exception thrown when dereferencing a null reference.
Pattern matching C# syntax for matching shapes/conditions; useful for clear and safe null checks.