Nullability
C# 8 introduced nullable reference types: an opt-in compile-time discipline that distinguishes string (non-nullable) from string? (nullable), with flow analysis that diagnoses dereferences of potentially-null values. Combined with the long-standing nullable value types (int?), the null-conditional operator (?.), the null-coalescing operator (??), and the null-forgiving operator (!), the language admits a complete null-safety story when the discipline is enabled. The mechanism is the C# response to the recurring problem of NullReferenceException — historically the most common exception in production C# code — and is the conventional choice in new code.
The mechanism is opt-in: it is disabled in pre-C# 8 code and is enabled per-project (or per-file) by the Nullable setting. New projects from .NET 6 onward have it enabled by default; legacy projects must enable it explicitly and migrate.
Nullable value types
A nullable value type — predating nullable reference types by sixteen years — wraps a value type with an additional flag indicating whether a value is present:
int? maybe_int = null;
bool? maybe_bool = true;
if (maybe_int.HasValue) {
int x = maybe_int.Value; // unwrap
}
int x2 = maybe_int ?? 0; // null-coalescing
int x3 = maybe_int.GetValueOrDefault(0);
int? is C# syntactic sugar for Nullable<int> (a generic value type). The wrapper carries:
- A
bool HasValueindicating whether a value is present. - An
int Valueaccessor that throwsInvalidOperationExceptionif no value is present. - Conversion to and from the underlying type.
- Lifted operators:
int? a + int? bis well-defined and is null if either operand is null.
The mechanism existed since C# 2 and is well-understood. Most C# code uses nullable value types where they are appropriate (database columns that may be null, optional configuration values, parsed input that may have failed).
Nullable reference types
C# 8 introduced nullable reference types: a compile-time annotation distinguishing nullable from non-nullable references.
#nullable enable
string name = "alice"; // non-nullable
string? maybe = null; // nullable
string s = maybe ?? "default"; // null-coalescing produces non-null
void greet(string name) {
Console.WriteLine($"hello, {name.ToUpper()}");
// .ToUpper() is safe: name is non-nullable
}
void try_greet(string? maybe_name) {
Console.WriteLine($"hello, {maybe_name.ToUpper()}");
// ^^^^^^^^^^
// warning: dereference of a possibly-null reference
}
The compiler performs flow analysis to track whether each variable is provably non-null at each point. The annotations are conservative: code that the compiler cannot prove non-null is treated as potentially null and produces a diagnostic on dereference.
Crucially, the mechanism is a warning discipline — the runtime does not actually enforce the annotations. A non-nullable string may still be null at runtime if the caller bypasses the warnings or if data crosses the boundary from non-annotated code. The annotations are a programmer-visible discipline backed by static analysis; they do not change the runtime behaviour of null.
The nullable annotation context
The compiler’s interpretation of T? and T depends on whether the nullable annotation context is enabled. Three levels of granularity:
Project-level
In the .csproj:
<PropertyGroup>
<Nullable>enable</Nullable> <!-- annotations + warnings -->
</PropertyGroup>
The values:
enable— annotations and warnings are on.warnings— warnings on; annotations off (T?is allowed butTis not non-nullable).annotations— annotations on; warnings off (the analyser tracks the types but issues no diagnostics).disable— both off (the C# 7 behaviour).
The default for new projects in .NET 6+ templates is enable. Projects targeting earlier .NET versions or migrating from older code typically start with disable and migrate file-by-file.
File-level
A pragma at the top of a file overrides the project-level setting:
#nullable enable
// or
#nullable disable
// or
#nullable enable warnings
The pragma applies until the next #nullable directive or the end of the file. The mechanism is the conventional way to incrementally enable nullability in a legacy codebase.
Local
#nullable enable
class Foo {
string Bar(string? x) {
#nullable disable
return x; // no warning here
#nullable enable
}
}
Rare; mostly used when a single block of code is genuinely incompatible with the nullability rules.
Null-conditional ?.
The null-conditional operator performs a member access only if the receiver is non-null; otherwise the entire expression yields null:
string? name = GetName();
int? len = name?.Length; // null if name is null
person?.Address?.Street?.ToUpper(); // chains; any null short-circuits
The construction admits indexers (?[i]) and method invocation (?.Method()); the receiver is evaluated once.
Null-coalescing ?? and ??=
The null-coalescing operator returns its left operand if non-null, otherwise its right:
string display = name ?? "unknown";
The null-coalescing assignment (C# 8) assigns only if the target is null:
private List<string>? cache;
public List<string> GetCache() {
cache ??= new List<string>();
return cache;
}
The combination of ?., ??, and ??= is the conventional null-handling toolkit.
The null-forgiving operator !
The ! suffix asserts that an expression is non-null, even when the compiler cannot prove it:
string? maybe = GetName();
string name = maybe!; // assert: maybe is not null at runtime
// (no runtime check; failure produces NullReferenceException later)
The operator is a programmer assertion, not a runtime check. It tells the compiler “I know this is non-null; trust me”; the compiler suppresses the warning. The conventional uses:
- The null-state analysis is incorrect for a particular case, and the programmer can verify by inspection.
- The result is from an external API that the compiler cannot annotate, but the contract guarantees non-null.
The construction is occasionally necessary but should be rare; an over-used ! undermines the discipline.
Null-state analysis
The compiler tracks each variable’s null state through the method:
void process(string? input) {
if (input == null) return;
Console.WriteLine(input.Length); // OK: input has been narrowed to non-null
}
The analysis follows assignments, comparisons, conditional logic, and method calls:
void check(string? s) {
if (s is null) return; // narrows s to non-null after this
Console.WriteLine(s.Length); // OK
}
void try_value(string? s) {
if (string.IsNullOrEmpty(s)) {
// s is potentially null
return;
}
Console.WriteLine(s.Length); // OK: IsNullOrEmpty narrows
}
The standard library uses nullable annotation attributes — [NotNull], [NotNullWhen], [MaybeNull], [NotNullIfNotNull], [DoesNotReturn] — to convey null-state information that the compiler cannot infer. The attributes appear on standard-library APIs and may be used by user code:
using System.Diagnostics.CodeAnalysis;
public static bool TryGet([NotNullWhen(true)] out string? value) {
/* if returns true, value is non-null */
}
if (TryGet(out var v)) {
Console.WriteLine(v.Length); // OK: NotNullWhen(true) narrows
}
The attributes are part of the discipline and produce significantly better warnings than the language alone could.
Migrating an existing codebase
The conventional migration path:
- Enable
<Nullable>warnings</Nullable>(warnings only; no annotations) for the whole project. Most existing code compiles unchanged. - Pick one file to migrate. Add
#nullable enableat the top. - The compiler produces warnings for every member that should be non-nullable but is not annotated, and for every dereference of a potentially-null value. Fix each: change
stringtostring?where null is possible, initialise non-null fields in the constructor, or add!where the analysis is wrong. - Repeat for each file.
- Once all files are migrated, change the project setting to
<Nullable>enable</Nullable>and delete the per-file pragmas.
The migration is incremental: at any point, partially-migrated code compiles and runs. Library authors typically annotate their public API first, since the annotations are part of the contract.
Common patterns
Validating non-null parameters
Pre-C# 11:
public void Process(string input) {
if (input is null) throw new ArgumentNullException(nameof(input));
// ...
}
C# 11 introduced parameter null-checking with the !! syntax (since reverted), which the language committee ultimately removed in favour of explicit ArgumentNullException.ThrowIfNull:
public void Process(string input) {
ArgumentNullException.ThrowIfNull(input);
// ...
}
The latter is the conventional contemporary form.
Initialising non-null fields
A non-nullable field must be initialised in every constructor:
public class Document {
public string Title { get; }
public string Body { get; }
public Document(string title, string body) {
Title = title; // both must be assigned
Body = body;
}
}
Required fields (C# 11) admit a stronger assertion: the field must be initialised in any object initialiser that constructs the instance:
public class Document {
public required string Title { get; init; }
public required string Body { get; init; }
}
var d = new Document { Title = "x", Body = "y" }; // OK
var e = new Document { Title = "x" }; // ERROR: Body required
is null and is not null
The pattern-matching forms are the conventional null-check:
if (value is null) {
// ...
}
if (value is not null) {
// value's null-state is narrowed to non-null
}
The forms are equivalent to value == null and value != null for most cases but cannot be overridden by ==/!= operator overloading; they always test reference identity for reference types and value-equality-with-null for nullable value types. The conventional advice is to prefer is null and is not null for clarity and safety.
Conditional dereference
person?.Name ?? "unknown"
The combination is the conventional one-liner: dereference if non-null, otherwise use a default.
A note on what the discipline does not catch
The nullability annotations are a static analysis; they do not change runtime behaviour. Several cases remain:
- Data deserialised from JSON, databases, or other external sources may produce null even for non-nullable fields. The conventional defence is post-deserialisation validation.
- Reflection-based code may bypass the annotations.
- Interop with non-annotated libraries (older code, C# 7 code, legacy DLLs) may produce null where the annotations claim non-null.
- The
default(T)of a non-nullable reference type isnull; the analysis warns but does not prevent.
The discipline is therefore necessary but not sufficient: it catches a substantial fraction of the null-related defects, but not all. The conventional contemporary advice is to enable the annotations, treat warnings as errors, validate at boundary points (deserialisation, external APIs), and use the standard-library validation helpers (ArgumentNullException.ThrowIfNull) at public-method entry.