Skip to content

C# Nullable Reference Types — Annotations, Warnings, and Null-Forgiving Operator

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about C# Nullable Reference Types. We cover key concepts, practical examples, and best practices to help you master this topic.

C# nullable reference types enable compile-time null safety through type annotations (? suffix), static flow analysis that issues warnings, and the null-forgiving operator (!) for explicit bypass.

What You'll Learn

You will master nullable reference types in C#: enabling nullable annotations in your project, distinguishing between nullable and non-nullable reference types, understanding the compiler's static flow analysis, using the null-forgiving operator, and writing null-safe .NET code.

Why It Matters

Null reference exceptions are the most common runtime error in .NET applications. Nullable reference types shift null checking from runtime (NullReferenceException) to compile time (warnings). This feature, introduced in C# 8, dramatically reduces null-related bugs. All modern .NET projects should enable nullable reference types.

Real-World Use

ASP.NET Core and .NET runtime libraries are fully annotated with nullable reference types. API controllers use nullable annotations for optional parameters. EF Core queries return nullable types for database columns that allow null. Configuration values that may be missing are modeled as nullable.

Learning Path

graph LR
    A["25: Extension Methods"] --> B["26: Nullable Reference Types"]
    B --> C["27: Pattern Matching"]
    C --> D["28: Records & Structs"]
    D --> E["29: Async Await"]
    style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style B fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style C fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style D fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style E fill:#4a90d9,stroke:#2c5f8a,color:#fff

Enabling Nullable Reference Types

In the .csproj file:

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

Or per-file using directives:

#nullable enable
// Code with nullable annotations
#nullable disable

Nullable vs Non-Nullable References

#nullable enable

string nonNull = "Hello";        // Cannot be null (compiler enforces)
string? nullable = null;          // Can be null

// nonNull = null;  // Warning: cannot convert null to non-nullable reference

Console.WriteLine(nonNull.Length);  // Safe
Console.WriteLine(nullable.Length); // Warning: possible null dereference

Flow Analysis

The compiler tracks null state through flow analysis:

string? maybeNull = GetValue();

// Compiler tracks null state
if (maybeNull != null)
{
    Console.WriteLine(maybeNull.Length);  // Safe: compiler knows it's not null
}

// Null-conditional operator
int? length = maybeNull?.Length;  // Safe: returns null if maybeNull is null
Console.WriteLine(length);

// Null-coalescing
string result = maybeNull ?? "Default";
Console.WriteLine(result.Length);  // Safe: result is non-null

// Pattern matching
if (maybeNull is string str)
{
    Console.WriteLine(str.Length);  // Safe: str is non-null
}

Nullable Annotations in Method Signatures

public class UserService
{
    // Return type can be null
    public User? GetUserById(int id)
    {
        // Return null if not found
        return null;
    }

    // Parameter can be null
    public bool ValidateUser(User? user)
    {
        return user != null && user.IsActive;
    }

    // Non-nullable parameter (compiler enforces)
    public void SaveUser(User user)
    {
        // user guaranteed non-null
    }

    // Out parameter with MaybeNull
    public bool TryGetUser(int id, [MaybeNull] out User user)
    {
        user = null;
        return false;
    }
}

Null-Forgiving Operator (!)

Use ! when you know a value is not null but the compiler cannot prove it:

string? maybeNull = GetValue();

// Compiler warning: possible null
// int len = maybeNull.Length;

// Suppress warning (use only when you are sure)
int len = maybeNull!.Length;

// Practical example with dictionaries
Dictionary<string, string> dict = new()
{
    ["key"] = "value"
};

// Compiler does not know the key exists
string value = dict["key"]!;  // We know this key exists

// With nullable attributes
public class Person
{
    public string Name { get; set; } = null!;  // Initialized by constructor/EF Core
}

Nullable Attributes

using System.Diagnostics.CodeAnalysis;

public class JsonHelper
{
    // AllowNull: input can be null even though type is non-nullable
    public static void SetValue([AllowNull] string value)
    {
        // May accept null internally
    }

    // MaybeNull: return value may be null even though type is non-nullable
    [return: MaybeNull]
    public static string GetValue()
    {
        // May return null even though type says non-nullable
        return default;
    }

    // NotNull: parameter is not null even if type says nullable
    public static void Process([NotNull] string? value)
    {
        // value is guaranteed not null after method call
        value!.ToString();  // No warning needed
    }

    // MemberNotNull: property is initialized after method call
    [MemberNotNull(nameof(Name))]
    public void Initialize()
    {
        Name = "Default";
    }

    public string Name { get; set; }
}

Nullable and Generics

// For value types, T? means Nullable<T>
public T? Find<T>(IEnumerable<T> items, Func<T, bool> predicate) where T : struct
{
    foreach (var item in items)
        if (predicate(item)) return item;
    return null;
}

// For reference types, T? means nullable annotation
public T? FindRef<T>(IEnumerable<T> items, Func<T, bool> predicate) where T : class
{
    foreach (var item in items)
        if (predicate(item)) return item;
    return null;
}

// The pattern (C# 9+):
public T? FindAny<T>(IEnumerable<T> items, Func<T, bool> predicate)
    where T : class?
{
    foreach (var item in items)
        if (predicate(item)) return item;
    return null;
}

Handling Null in Legacy Code

#nullable disable
// Legacy code without nullable annotations
public class OldClass
{
    public string GetValue() => null;  // No warnings
}
#nullable enable

// Interacting with legacy code
var old = new OldClass();
string value = old.GetValue();  // Warning: possible null from non-annotated code

Best Practices

// GOOD: Nullable parameter with null check
public void Process(int? value)
{
    if (value.HasValue)
        Console.WriteLine(value.Value);
}

// GOOD: Return nullable when value may not exist
public User? FindUser(int id) { /* ... */ }

// GOOD: Use null-forgiving sparingly
// BAD: Overuse of ! hides real null issues

// GOOD: Constructor ensures non-null
public class Product
{
    public string Name { get; set; } = string.Empty;  // No null issues
}

// GOOD: Use TryGet pattern
public bool TryParse(string input, [NotNullWhen(true)] out int? result)
{
    // ...
}

Common Mistakes

Mistake 1: Not Enabling Nullable Reference Types

#nullable disable (the default for legacy projects) bypasses all null safety. Always enable nullable in new projects. Add Nullable>enable</Nullable> to your csproj.

Mistake 2: Overusing the Null-Forgiving Operator (!)

Using ! everywhere defeats the purpose of nullable annotations. Only use it when you have specific knowledge the compiler lacks.

Mistake 3: Ignoring Nullable Warnings

Treat nullable warnings as errors. Each warning represents a potential null reference exception at runtime.

Mistake 4: Using string.Empty Instead of ""

Both are fine. But string.Empty is not a compile-time constant, which may cause issues in some attribute contexts.

Mistake 5: Forgetting to Annotate Generic Types

T? behaves differently for value types (becomes Nullable<T>) and reference types (stays T with annotation). Use appropriate constraints.

Mistake 6: Mixing Nullable Enabled and Disabled Contexts

When calling from nullable-enabled code to disabled code, the compiler assumes all reference types from the disabled context are non-nullable but may actually be null.

Practice Questions

  1. What is the difference between a nullable value type (int?) and a nullable reference type (string?)?
  2. How does the null-forgiving operator (!) work? When should you use it?
  3. What is the purpose of the [NotNullWhen(true)] attribute?
  4. How do you enable nullable reference types for an entire project?
  5. Write a method that returns a nullable string and demonstrate proper null checking on the result.

Challenge

Write a null-safe configuration reader that reads values from a dictionary. Use nullable reference types to ensure that missing keys are handled gracefully without null reference exceptions.

FAQ

Do nullable reference types affect runtime behavior?

No. Nullable reference types are a compile-time feature only. They add warnings and annotations but do not change runtime behavior. The ? annotation is removed during compilation.

What happens at runtime if a non-nullable reference is null?

Nothing at the language level. The compiler warns during compilation, but at runtime the null value exists. You still get a NullReferenceException if you dereference it.

Can I make nullable warnings into errors?

Yes. Add WarningsAsErrors>CS8600;CS8602;CS8603</WarningsAsErrors> to your csproj to treat common nullable warnings as errors.

What is the difference between `[AllowNull]` and `[MaybeNull]`?

AllowNull says the parameter can be null even though the type says non-nullable. MaybeNull says the return value may be null even though the type says non-nullable.

Do I need nullable annotations in interfaces?

Yes. Annotating interfaces is important because all implementations inherit the contract. Properly annotated interfaces provide null safety for all consumers.

Mini Project

Create a null-safe configuration reader:

public class ConfigurationReader
{
    private readonly Dictionary<string, string?> _settings;

    public ConfigurationReader(Dictionary<string, string?> settings)
    {
        _settings = settings;
    }

    public string? GetString(string key) =>
        _settings.TryGetValue(key, out var value) ? value : null;

    public string GetString(string key, string defaultValue) =>
        GetString(key) ?? defaultValue;

    public int? GetInt(string key)
    {
        var value = GetString(key);
        return int.TryParse(value, out var result) ? result : null;
    }

    public int GetInt(string key, int defaultValue) =>
        GetInt(key) ?? defaultValue;

    public bool? GetBool(string key)
    {
        var value = GetString(key);
        return value?.ToLower() switch
        {
            "true" or "1" or "yes" => true,
            "false" or "0" or "no" => false,
            _ => null
        };
    }

    public T? GetObject<T>(string key) where T : class
    {
        var value = GetString(key);
        if (value == null) return null;
        return System.Text.Json.JsonSerializer.Deserialize<T>(value);
    }

    public bool HasKey(string key) => _settings.ContainsKey(key);
}

var settings = new Dictionary<string, string?>
{
    ["AppName"] = "MyApp",
    ["MaxRetries"] = "3",
    ["Enabled"] = "true",
    ["Timeout"] = null,
    ["NestedConfig"] = """{"Host": "localhost", "Port": 8080}"""
};

var config = new ConfigurationReader(settings);

string appName = config.GetString("AppName", "DefaultApp");
int maxRetries = config.GetInt("MaxRetries", 1);
bool? enabled = config.GetBool("Enabled");
int? timeout = config.GetInt("Timeout");
string? missing = config.GetString("MissingKey");

Console.WriteLine($"AppName: {appName}");          // MyApp
Console.WriteLine($"MaxRetries: {maxRetries}");    // 3
Console.WriteLine($"Enabled: {enabled}");           // True
Console.WriteLine($"Timeout: {timeout?.ToString() ?? "null"}");  // null
Console.WriteLine($"Missing: {missing ?? "null"}");  // null

var nested = config.GetObject<NestedConfig>("NestedConfig");
if (nested != null)
    Console.WriteLine($"Nested: {nested.Host}:{nested.Port}");

public class NestedConfig
{
    public string Host { get; set; } = "";
    public int Port { get; set; }
}

Expected output:

AppName: MyApp
MaxRetries: 3
Enabled: True
Timeout: null
Missing: null
Nested: localhost:8080

What's Next

You have mastered nullable reference types in C#. The next lesson covers pattern matching: switch expressions, property/positional/tuple patterns, and list patterns.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro