Skip to content

Nullable Reference Types (NRT)

Key Points

  • NRT is a compile-time feature; nothing changes at runtime. string? and string produce the same IL — but the compiler emits annotations ([Nullable]) that flow analysis consumes.
  • Enable globally: <Nullable>enable</Nullable> in Directory.Build.props. Toggle per-file with #nullable enable|disable|warnings|annotations.
  • T? on a reference type means "may be null". T (no ?) is "should not be null" — the compiler warns when you assign null or dereference without checking.
  • The null-forgiving operator ! (e.g., obj!.Method()) suppresses warnings but does not check at runtime. Treat it as a "trust me" — preferably with a comment.
  • Annotation attributes like [NotNullWhen(true)], [MaybeNullWhen(false)], [NotNull], [DoesNotReturn] teach flow analysis about your custom validation methods.
  • Generic null states are tricky: T? for an unconstrained T is "default(T)", which is null for ref types and zero for value types. Use [NotNullWhen(true)] on TryGet-style APIs.
  • NRT is not type safety in the strict sense — flow analysis is best-effort. It doesn't catch every NRE, but it catches a lot.

Concepts (deep dive)

What NRT actually does

NRT splits "reference type" into two states:

Annotation Compiler treats as Runtime
string "not null" (warns on null assign / null deref) identical to before — string
string? "maybe null" (warns on deref without check) identical to before — string

The runtime has no idea about the difference. Two libraries built with different NRT settings interoperate at runtime; the only difference is what the compiler chose to warn about.

Enabling NRT

<!-- Directory.Build.props (recommended for new code) -->
<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

Or in a .csproj:

<Nullable>enable</Nullable>

Per-file overrides:

#nullable disable          // suppress all NRT in this file
#nullable enable warnings  // emit warnings but don't honor annotations
#nullable enable annotations // honor annotations but suppress warnings
#nullable enable           // both — the default when project is enabled

Flow analysis basics

public int Length(string? s)
{
    if (s is null) return 0;
    return s.Length;        // ✅ flow analysis: s is not null here
}

public int LengthBad(string? s)
{
    return s.Length;        // ⚠️ CS8602: dereference of possibly null reference
}

public int LengthGuard(string? s)
{
    if (s is { } notNull)   // pattern-match: notNull is non-null string
        return notNull.Length;
    return 0;
}

The compiler tracks per-variable "null state" through your code: NotNull, MaybeNull, Unknown. Branches refine the state.

Null-forgiving operator !

public Customer? FindCustomer(int id) => /* ... may return null ... */;

var c = FindCustomer(42)!;   // "I know it's not null"
c.Send();                     // no warning

! does not add a runtime null check. It just tells the compiler to trust you. If you're wrong, you get an NullReferenceException at runtime.

⚠️ Senior advice: treat every ! as a code smell. It's sometimes the right answer (e.g., DI containers always return non-null for registered services), but always pair it with a comment explaining why.

Custom validation: annotation attributes

public static bool TryGet<T>(this Dictionary<string, T> d, string key,
                             [NotNullWhen(true)] out T? value) where T : notnull
    => d.TryGetValue(key, out value);

[NotNullWhen(true)] means: when this method returns true, the out parameter is non-null. Flow analysis uses it:

if (dict.TryGet("k", out var v))
{
    v.UseAsNonNull();   // ✅ no warning
}

Common attributes:

Attribute Meaning
[NotNull] Parameter/return is never null even if the type says T?
[MaybeNull] Parameter/return could be null even if type says T (e.g., default(T) for unconstrained generic)
[NotNullWhen(true|false)] If the method returns the given bool, the annotated arg is non-null
[MaybeNullWhen(true|false)] If the method returns the given bool, the annotated arg could be null
[NotNullIfNotNull(nameof(arg))] Result is non-null when arg is non-null (e.g., string? Trim(string? s))
[DoesNotReturn] Method never returns (throws / Environment.Exit) — flow analysis treats post-call as unreachable
[DoesNotReturnIf(true|false)] Method doesn't return when arg has the given value (used by Debug.Assert)

Generic null states

public T? GetOrDefault<T>(string key)
{
    if (_cache.TryGetValue(key, out var v)) return v;
    return default;     // null for ref types, default for value types
}

For unconstrained T, T? denotes "the default value": null for string?, 0 for int? (well, technically int? is Nullable<int>, but you get the idea).

// Constrained: only ref types
public T? GetOrDefault<T>(string key) where T : class { /* ... */ }

// Constrained: only non-nullable
public T GetOrThrow<T>(string key) where T : notnull { /* ... */ }

The notnull constraint is itself an NRT feature: it disallows passing nullable type arguments.

Common false positives and how to handle them

Initialization-order issues:

public class Service
{
    public string ConnectionString { get; }
    private readonly DbConnection _conn;   // ⚠️ CS8618 if no init

    public Service(IConfiguration cfg)
    {
        ConnectionString = cfg["Db:Conn"] ?? throw new InvalidOperationException();
        _conn = new SqlConnection(ConnectionString);
    }
}

Solutions: 1. Mark with = null!; if assigned in constructor: private readonly DbConnection _conn = null!; 2. Use required (C# 11+) for properties: public required string Name { get; init; }. 3. Suppress with [MemberNotNull(nameof(_conn))] on a private init method that the constructor calls.

External code without NRT:

// Calling a library not built with NRT
SomeOldApi.GetConfig()    // returns string, but library lacks NRT — could be null
    .ToUpper();           // ⚠️ depending on context, may warn; otherwise risk NRE

Wrap at the boundary, document the contract, add ? and a check.

required members (C# 11+)

public class Customer
{
    public required string Name { get; init; }
    public required string Email { get; init; }
}

var c = new Customer();   // ❌ compile error: must set required members
var c2 = new Customer { Name = "Ada", Email = "ada@x" };   // ✅

required shifts initialization-order non-nullness from the constructor (where NRT struggles) to the object initializer (where the compiler can verify all required members are set before completion).


How it works under the hood

NRT is implemented entirely in Roslyn (the C# compiler). When the project enables NRT:

  1. Annotation context — the compiler treats unannotated reference types as non-null.
  2. Warning context — the compiler emits CS86xx-series warnings on null-state violations.
  3. Per-method flow analysis — the compiler tracks null state at each program point.
  4. Output annotations — the compiler emits [NullableAttribute] and [NullableContextAttribute] on the metadata of public/internal members so consumers can read your annotations.

The runtime cost is zero. NRT does not insert null checks, throw helpers, or anything else. The IL emitted for string and string? is byte-identical.

The compiler stores nullability info as an attribute byte stream where 0=oblivious, 1=non-null, 2=nullable. Generic types like Dictionary<string, List<int>?> emit one byte per type position.


Code: correct vs wrong

❌ Wrong: ignoring or suppressing warnings reflexively

public string Build(Order? o) => o!.Description.ToUpper();
// Two `!` worth of trust required to silence — you're probably wrong.

✅ Correct: surface the contract

public string Build(Order o)
    => o.Description.ToUpper();   // require non-null at boundary

// or, if null is legitimate:
public string Build(Order? o)
    => o?.Description?.ToUpper() ?? "(unknown)";

❌ Wrong: assigning null! to satisfy non-null fields

public class Service
{
    private readonly DbConnection _conn = null!;   // ⚠️ runtime NRE if used before init
    public Service() { /* forgets to init _conn */ }
}

✅ Correct: required (C# 11+) or constructor assignment

public class Service
{
    private readonly DbConnection _conn;
    public Service(string connStr)
    {
        _conn = new SqlConnection(connStr);
    }
}

❌ Wrong: writing Try* without annotation

public bool TryFind(string key, out Customer? customer)
{
    /* ... */
    customer = found; return found is not null;
}
// Caller can't easily use customer as non-null after a true return.

✅ Correct: [NotNullWhen(true)]

public bool TryFind(string key, [NotNullWhen(true)] out Customer? customer)
{
    /* ... */
    customer = found; return found is not null;
}

if (svc.TryFind("k", out var c))
    c.UseAsNonNull();   // ✅ flow-analyzed

❌ Wrong: extension method silently nullable

public static string Upper(this string? s) => s!.ToUpper();
// Caller has no warning if they pass null — and gets NRE.

✅ Correct: explicit handling and signature

public static string Upper(this string? s) => s?.ToUpper() ?? "";
// Or:
[return: NotNullIfNotNull(nameof(s))]
public static string? Upper(this string? s) => s?.ToUpper();

Design patterns for this topic

Pattern 1 — "Validate at the boundary, trust within"

  • Intent: NRT works best when null possibilities are eliminated at the entry points of your module.
  • When to use it: application services, controllers — anywhere external input arrives.
  • Code sketch:
public Result<Customer> GetCustomer(string? id)
{
    if (string.IsNullOrEmpty(id)) return Result.Fail("missing id");
    // Below this line, id is non-null and non-empty.
    return _repo.Find(id) is { } customer ? Result.Ok(customer) : Result.Fail("not found");
}

Pattern 2 — "required for mandatory init, nullable for optional"

  • Intent: push initialization checks to the object-initializer level.
  • When to use it: DTOs and config objects.
  • Code sketch:
public class ServiceOptions
{
    public required string Endpoint { get; init; }
    public string? UserAgent { get; init; }
    public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}

Pattern 3 — "Try methods with [NotNullWhen(true)]"

  • Intent: familiar pattern that works smoothly with NRT.
  • When to use it: anywhere a "may not exist" outcome is expected.
  • Code sketch: see "correct vs wrong" #3.

Pattern 4 — "Annotate your custom validators"

  • Intent: teach the flow analyzer to trust your guard methods.
  • Code sketch:
public static class Guard
{
    public static T NotNull<T>([NotNull] T? value, string name)
        => value ?? throw new ArgumentNullException(name);
}

public void Use(string? input)
{
    Guard.NotNull(input, nameof(input));
    Console.WriteLine(input.Length);   // ✅ flow-analyzed
}

[NotNull] says: if this method returns normally, the parameter is non-null.

Pattern 5 — "Boundary types and [MemberNotNull] for late-init"

  • Intent: for fields initialized in a non-constructor method called from the constructor.
  • Code sketch:
public class LateInit
{
    private string _name;       // ⚠️ would warn

    public LateInit(IConfiguration cfg) => Init(cfg);

    [MemberNotNull(nameof(_name))]
    private void Init(IConfiguration cfg)
    {
        _name = cfg["Name"] ?? throw new();
    }
}

[MemberNotNull] tells the compiler "after this method returns, these members are non-null". Constructor flow analysis honors it.


Pros & cons / trade-offs

Aspect Pros Cons
NRT enabled Catches a lot of bugs at compile time Initial migration cost; some libraries lack annotations
? on parameters Documents intent More noise in signatures
required Compile-time enforcement of init Only works at object-initializer level, not constructor-only DI
[NotNullWhen(true)] and family Teaches flow analyzer Easy to forget; verbose syntax
! operator Escape hatch Easy to abuse; hides real bugs

When to use / when to avoid

  • Use NRT in every new project. It's free; the migration cost on day 1 is small.
  • Migrate existing codebases gradually: start with <Nullable>enable</Nullable> then suppress warnings file-by-file with #nullable disable warnings until you're ready.
  • Use required for DTOs and config objects to push validation to call sites.
  • Avoid the ! operator in production code unless paired with a comment.
  • Avoid null! field initializers — use required or constructor-assigned with proper null checks.
  • Avoid silently disabling NRT in libraries you publish — consumers benefit from your annotations.

Interview Q&A

Q1. Does NRT change runtime behavior? No. NRT is purely compile-time. The IL emitted for string and string? is identical. Warnings are emitted; no checks are inserted.

Q2. What does ! do? The null-forgiving operator. Tells the compiler "trust me, this is not null" without inserting a runtime check. If you're wrong, you get NullReferenceException.

Q3. What's the difference between T? for reference types vs value types? string? is just string with an annotation. int? is Nullable<int> — a different runtime type with HasValue/Value. The ? is overloaded syntax meaning different things by type kind.

Q4. How do you tell the flow analyzer that your Try method's out is non-null on success? [NotNullWhen(true)] out T? value. Flow analyzes: "if the method returned true, value is non-null".

Q5. What's required? A C# 11+ keyword that forces consumers to set the property in object initialization. Combined with init, gives you "must-set, set-once" semantics. Solves NRT's struggle with multi-step constructor init.

Q6. How do you migrate a large codebase to NRT? Enable globally with <Nullable>enable</Nullable>. Initially expect lots of warnings. Use #nullable disable warnings per file to defer; tackle file-by-file. Treat warnings as warnings (not errors) until cleanup is done. Annotate library boundaries first; flow inward.

Q7. What's [MemberNotNull] for? Tells the compiler that after a method returns, certain this-fields are non-null. Useful for constructors that delegate field init to a private helper.

Q8. What's [DoesNotReturn] and why does it matter for flow analysis? Marks a method as never returning normally (throws / exits). Flow analyzer treats post-call as unreachable, so if (x is null) ThrowHelper.Throw(); correctly results in x being non-null afterward — without ThrowHelper.Throw() being annotated, the compiler would still warn.

Q9. Why might your code work fine but you get a "possible null" warning? Flow analysis is conservative. It doesn't always understand custom assertions or invariants. Solutions: annotation attributes ([NotNull], [MemberNotNull]), or in last resort, the ! operator with a comment.

Q10. How does NRT interact with deserialization (e.g., System.Text.Json)? The deserializer can produce a Customer with Name = null even though Name is non-nullable. NRT doesn't help; you have to validate post-deserialization or use required. With required, deserializers in modern .NET enforce that required properties are present in input.

Q11. What's notnull constraint? where T : notnull. Disallows nullable type arguments — Foo<string?> won't compile if Foo<T> where T : notnull. Useful in collections (Dictionary<TKey, TValue> where TKey : notnull).

Q12. How does the compiler know about a library's nullability annotations? Compiled into metadata as [Nullable] and [NullableContext] attributes. Roslyn reads them when consuming the library. If a library was built without NRT, its types are "oblivious" — neither nullable nor non-nullable, and the compiler doesn't warn about them.


Gotchas / common mistakes

  • ⚠️ Treating NRT as type safety — it's flow analysis, not theorem proving. You can still NRE.
  • ⚠️ Sprinkling ! — silences the warning, doesn't fix the bug.
  • ⚠️ null! initializers on fields — initial value is null at runtime; first use crashes.
  • ⚠️ Not annotating public APIs — consumers of your library lose half the benefit.
  • ⚠️ Forgetting [NotNullWhen(true)] on Try* — callers can't easily use out-result as non-null.
  • ⚠️ Generic T? confusion — for unconstrained T, T? means default(T). May or may not be what you want.
  • ⚠️ Deserialized objects bypassing NRTSystem.Text.Json happily produces null in non-null properties unless you guard or use required.
  • ⚠️ NRT mismatches in inheritance — overriding a string with a string? requires explicit covariance handling.

Further reading