Nullable Types and Null Safety
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
C# has two separate null stories. Nullable value types (int?) are a runtime feature dating to C# 2 — a real
struct wrapping a value and a flag. Nullable reference types (string?) are a C# 8 compile-time analysis:
there is no runtime difference, only warnings when you might dereference null.
Nullable Value Types
T? for a value type is System.Nullable<T>:
int? maybe = null;
Console.WriteLine(maybe.HasValue); // False
Console.WriteLine(maybe.GetValueOrDefault()); // 0
Console.WriteLine(maybe.GetValueOrDefault(-1)); // -1
Console.WriteLine(maybe ?? -1); // -1
maybe = 42;
Console.WriteLine(maybe.HasValue); // True
Console.WriteLine(maybe.Value); // 42
int? none = null;
try
{
Console.WriteLine(none.Value); // throws InvalidOperationException
}
catch (InvalidOperationException)
{
Console.WriteLine("Value on a null nullable throws");
}
// Pattern matching is the clean way to unwrap.
if (maybe is int value)
{
Console.WriteLine(value * 2); // 84
}
Lifted Operators
Operators on T are lifted to T?: if either operand is null, the result is null (or, for comparisons,
false).
int? a = 5;
int? b = null;
Console.WriteLine(a + 1); // 6
Console.WriteLine(a + b); // (blank) -- null
Console.WriteLine((a + b) ?? 0); // 0
Console.WriteLine(a > 3); // True
Console.WriteLine(b > 3); // False
Console.WriteLine(b <= 3); // False -- BOTH comparisons are false with null!
Console.WriteLine(b == null); // True -- == and != behave as you expect
// The three-valued logic of bool? follows SQL.
bool? yes = true, no = false, unknown = null;
Console.WriteLine(yes & unknown); // (blank) -- null
Console.WriteLine(no & unknown); // False -- false regardless
Console.WriteLine(yes | unknown); // True -- true regardless
The b > 3 / b ⇐ 3 pair is the classic trap: with null they are both false, so if (b > 3) … else …
routes nulls into the else branch silently.
Boxing and Conversions
int? present = 42;
int? absent = null;
object boxedPresent = present; // boxes the int, NOT the Nullable<int>
object? boxedAbsent = absent; // becomes a plain null reference
Console.WriteLine(boxedPresent.GetType()); // System.Int32
Console.WriteLine(boxedAbsent is null); // True
// Implicit T -> T?, explicit T? -> T.
int plain = 7;
int? lifted = plain; // implicit
int back = (int)lifted; // explicit -- throws if null
Console.WriteLine($"{lifted} {back}");
Nullable Reference Types
With NRTs enabled, string means "not null" and string? means "may be null". The compiler tracks each
variable’s null-state through the method and warns when the two disagree.
#nullable enable
public sealed class Customer2
{
public required string Name { get; init; } // must not be null
public string? MiddleName { get; init; } // may be null
public string FullName =>
MiddleName is null ? Name : $"{Name} {MiddleName}";
}
public static class NrtBasics
{
public static int LengthOf(string? maybe)
{
// Console.WriteLine(maybe.Length); // warning CS8602: possible null dereference
if (maybe is null)
{
return 0;
}
return maybe.Length; // here the compiler knows it is not null
}
public static string Normalise(string? input) => input?.Trim() ?? string.Empty;
}
Enable it project-wide (the default in new templates):
<PropertyGroup>
<Nullable>enable</Nullable>
<!-- Optional, and recommended once clean: make the warnings errors. -->
<WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>
…or per file with #nullable enable / #nullable disable / #nullable restore, and per aspect with
#nullable enable warnings (analyse but do not change annotations) or #nullable enable annotations.
|
NRTs are advisory. They are erased at run time: |
Flow Analysis and Null-State
The compiler assigns every expression a null-state of not-null or maybe-null, and updates it as control flows:
maybe-null"] --> B{"if (s is null)"} B -->|"true branch"| C["null
s.Length → warning CS8602"] B -->|"false branch"| D["not-null
s.Length → fine"] D --> E["s = MightReturnNull();"] E --> F["maybe-null again
the assignment resets the state"] F --> G{"if (!string.IsNullOrEmpty(s))"} G -->|"true"| H["not-null
via [NotNullWhen(true)]
on the BCL method"] G -->|"false"| I["maybe-null"] H --> J["foreach / lambda / another method call
does NOT reset the state
unless it can assign s"] style C fill:#fdf3f3,stroke:#b5523d style D fill:#eefaf2,stroke:#2f7d51 style H fill:#eefaf2,stroke:#2f7d51 style F fill:#fff4e8,stroke:#b5762a
#nullable enable
public static class FlowAnalysis
{
public static void Demo(string? input)
{
// Each of these narrows the state to not-null in the body:
if (input != null) { Console.WriteLine(input.Length); }
if (input is not null) { Console.WriteLine(input.Length); }
if (input is string s) { Console.WriteLine(s.Length); }
if (!string.IsNullOrEmpty(input)) { Console.WriteLine(input.Length); }
// ?? and ?. participate too.
Console.WriteLine((input ?? "").Length);
Console.WriteLine(input?.Length ?? 0);
// An early return narrows for the rest of the method.
if (input is null)
{
return;
}
Console.WriteLine(input.Length); // not-null from here on
}
}
The Nullable Static-Analysis Attributes
These teach the compiler about contracts it cannot infer — essential when writing a library.
| Attribute | Says |
|---|---|
|
An input may be null even though its type says otherwise. |
|
An input must not be null even though its type is nullable. |
|
A return value / |
|
A return value / |
|
The |
|
The |
|
The result is not null whenever parameter |
|
After this method returns, the named member(s) are not null. |
|
…only when it returns this value. |
|
This method never returns (it always throws). |
|
It does not return when the argument has this value. |
#nullable enable
public static class NullabilityContracts
{
// The out parameter is meaningful only when the method returns true.
public static bool TryFind(IEnumerable<string> source, string prefix,
[NotNullWhen(true)] out string? found)
{
foreach (string item in source)
{
if (item.StartsWith(prefix, StringComparison.Ordinal))
{
found = item;
return true;
}
}
found = null;
return false;
}
// null in, null out; non-null in, non-null out.
[return: NotNullIfNotNull(nameof(input))]
public static string? Normalise(string? input) => input?.Trim();
// Never returns -- the compiler stops analysing the code after a call.
[DoesNotReturn]
public static void Fail(string message) => throw new InvalidOperationException(message);
public static void Demo(IEnumerable<string> source)
{
if (TryFind(source, "a", out string? match))
{
Console.WriteLine(match.Length); // no warning -- NotNullWhen(true)
}
string normalised = Normalise(" x "); // no warning -- NotNullIfNotNull
Console.WriteLine(normalised.Length);
}
}
public sealed class LazyInitialised
{
private string? _value;
// Tells the compiler _value is not null once Initialize has returned.
[MemberNotNull(nameof(_value))]
public void Initialize() => _value = "ready";
[MemberNotNullWhen(true, nameof(_value))]
public bool IsReady => _value is not null;
public int Use()
{
Initialize();
return _value.Length; // no warning
}
public int UseIfReady() => IsReady ? _value.Length : 0; // no warning
}
Guard Helpers
#nullable enable
public static class Guarding
{
public static void Configure(string? host, int port, IReadOnlyList<string>? tags)
{
// The modern BCL guards -- each uses CallerArgumentExpression for the message.
ArgumentNullException.ThrowIfNull(host);
ArgumentException.ThrowIfNullOrWhiteSpace(host);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(port);
ArgumentOutOfRangeException.ThrowIfGreaterThan(port, 65535);
// After the guard the compiler treats `host` as not-null.
Console.WriteLine(host.ToUpperInvariant());
Console.WriteLine(tags?.Count ?? 0);
}
}
These are annotated [NotNull], so a ThrowIfNull both validates at run time and narrows the null-state — you get the check and the warning suppression from one line.
required as an Alternative to Constructor Parameters
#nullable enable
public sealed class Options2
{
// Without `required`, this would warn CS8618: non-nullable property uninitialised.
public required string ConnectionString { get; init; }
public required Uri Endpoint { get; init; }
public int Retries { get; init; } = 3;
public string? Region { get; init; }
}
public static class RequiredDemo2
{
public static void Run()
{
var options = new Options2
{
ConnectionString = "Host=localhost",
Endpoint = new Uri("https://example.com"),
};
Console.WriteLine($"{options.ConnectionString} {options.Retries} {options.Region ?? "-"}");
}
}
required gives constructor-like guarantees with object-initializer syntax, and satisfies the compiler’s
"non-nullable field must be initialised" rule without a constructor per property.
Migrating an Existing Codebase
Turning <Nullable>enable</Nullable> on in a large project produces thousands of warnings at once. A workable
order:
-
Start with warnings only. Set
<Nullable>warnings</Nullable>(or#nullable enable warnings) so the analysis runs but existing signatures keep their oblivious annotations. Measure the size of the problem. -
Enable per file, bottom-up. Add
#nullable enableto leaf files — domain models, value types, utilities — first. Their annotations then inform everything above them. -
Annotate honestly. When a parameter really can be null, mark it
?rather than reaching for!. The annotations are the deliverable; the warnings are just the mechanism. -
Use the attributes for real contracts.
[NotNullWhen]on everyTry…method,[MemberNotNull]on everyInitialize,[NotNullIfNotNull]on every pass-through. This removes far more warnings than!does, and correctly. -
Fix
CS8618withrequired, a constructor, or= null!only where a framework assigns the field (an EF Core navigation property, a deserialised DTO). -
Treat
!as a code smell with a comment. Each one is a claim you cannot prove; write down why. -
Turn the warnings into errors once a project is clean:
<WarningsAsErrors>nullable</WarningsAsErrors>. That is what stops the debt from coming back.
Three states exist per file and per project — enabled, disabled, and oblivious (code compiled before NRTs). Calls into oblivious code produce no warnings at all, which is why migrating your dependencies' surface (or annotating it yourself) matters as much as your own.
See Also
-
Operators and Expressions —
?.,??,??=and the null-forgiving!. -
Structs and Value Types —
Nullable<T>as a struct. -
Pattern Matching —
is null,is not nulland narrowing. -
Exceptions and Error Handling — the guard helpers and when to throw.