Nullable Reference Types (NRT)
Key Points
- NRT is a compile-time feature; nothing changes at runtime.
string?andstringproduce the same IL — but the compiler emits annotations ([Nullable]) that flow analysis consumes. - Enable globally:
<Nullable>enable</Nullable>inDirectory.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 assignnullor 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 unconstrainedTis "default(T)", which is null for ref types and zero for value types. Use[NotNullWhen(true)]onTryGet-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:
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:
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:
- Annotation context — the compiler treats unannotated reference types as non-null.
- Warning context — the compiler emits
CS86xx-series warnings on null-state violations. - Per-method flow analysis — the compiler tracks null state at each program point.
- 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 warningsuntil you're ready. - Use
requiredfor 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 — userequiredor 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)]onTry*— callers can't easily useout-result as non-null. - ⚠️ Generic
T?confusion — for unconstrainedT,T?meansdefault(T). May or may not be what you want. - ⚠️ Deserialized objects bypassing NRT —
System.Text.Jsonhappily producesnullin non-null properties unless you guard or userequired. - ⚠️ NRT mismatches in inheritance — overriding a
stringwith astring?requires explicit covariance handling.