Nullable reference types (NRT) in C# 14: nullable contexts, annotations and the null-forgiving operator, flow analysis, nullable attributes, generics, and the gaps where nulls still get through at run time.
Nullable reference types make nullability part of the declared type for static analysis: string means "should never be null" and string? means "may be null". The compiler tracks a null-state for every expression and warns when a maybe-null value is dereferenced or assigned to a non-nullable. It is compile-time only: there are no run-time checks, and ! merely silences a warning. Attributes like [NotNullWhen(true)] on TryGet methods or [MemberNotNull] on initialisers tell the analyser about contracts it cannot infer. Nulls can still arrive from deserialisation, arrays, default and unannotated code, so I still validate at the boundaries.
Nullable reference types (NRT, C# 8+) are a compile-time static analysis feature, not a new runtime type. With <Nullable>enable</Nullable>, string means "should never be null" and string? means "may be null". The compiler tracks the null-state of every expression through the code and warns when you might dereference null or assign null to a non-nullable location. At run time, nothing changes — string and string? are the same System.String.
<PropertyGroup>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors> <!-- recommended once clean -->
</PropertyGroup>
New .NET templates enable it by default. For legacy code you can migrate file by file with #nullable enable or use <Nullable>warnings</Nullable> first.
| Syntax | Meaning |
|---|---|
string name |
Non-nullable: never assign null, safe to dereference |
string? name |
Nullable: may be null, check before dereferencing |
name! |
Null-forgiving: "trust me, not null" — suppresses the warning, no runtime effect |
T? on unconstrained generic |
Nullable if T is a reference type; unchanged for value types |
where T : notnull |
T must be a non-nullable type |
Each variable has a null-state at every point: not-null or maybe-null.
string? email = customer.Email; // maybe-null
if (email is null) return; // after this line: not-null
var domain = email.Split('@')[1]; // no warning
var name = customer.Nickname ?? customer.FullName; // not-null if FullName is non-nullable
The compiler understands null checks (is null, == null, ?., ??, throw guards, ArgumentNullException.ThrowIfNull) and narrows the state. It also tracks fields: a non-nullable field not initialised in every constructor gives CS8618.
Flow analysis does not look inside method bodies of callees, so APIs describe their behaviour with attributes from System.Diagnostics.CodeAnalysis:
| Attribute | Example | Effect |
|---|---|---|
[NotNullWhen(true)] |
bool TryGet(string key, [NotNullWhen(true)] out Customer? c) |
c is not-null when the method returns true |
[MaybeNullWhen(false)] |
Generic TryGetValue with out T value |
value may be default/null on false |
[NotNull] |
void EnsureLoaded([NotNull] ref Data? d) |
Argument is not-null after the call |
[MemberNotNull(nameof(_conn))] |
On an Init() method |
_conn is not-null after Init() returns (fixes CS8618 for init helpers) |
[MemberNotNullWhen(true, nameof(Value))] |
On bool IsSuccess |
Value is not-null when IsSuccess is true |
[NotNullIfNotNull(nameof(input))] |
string? Normalise(string? input) |
Result not-null when input is not-null |
[DoesNotReturn] / [DoesNotReturnIf(false)] |
Throw helpers, Debug.Assert |
Code after the call is unreachable |
[AllowNull] / [DisallowNull] |
Property setters/getters with asymmetric rules | Fine-grained in/out contracts |
The BCL is fully annotated, so string.IsNullOrEmpty(s) already narrows s to not-null on false.
NRT gives no runtime guarantee:
! operators, reflection, default in generics, and deserialisers can all put null into a non-nullable property.System.Text.Json ignores annotations unless you opt in: .NET 9 added RespectNullableAnnotations and RespectRequiredConstructorParameters options.NOT NULL columns and required relationships.new string[10] is typed string[] but full of nulls — the compiler cannot track elements.default(MyStruct).So public API boundaries still need guards: ArgumentNullException.ThrowIfNull(arg) and request validation.
On a large legacy service, NullReferenceExceptions were the top unhandled exception in Application Insights. We enabled NRT project by project, starting with the domain and contracts libraries, using <Nullable>warnings</Nullable> first and fixing ~400 warnings. The most valuable fixes were not the obvious ones: the analysis exposed DTO properties that were genuinely optional but treated as mandatory, and Try methods missing [NotNullWhen(true)], which had trained developers to scatter !. After the migration we made nullable warnings errors in CI and banned ! in code review unless justified with a comment. NREs in production dropped to a handful, mostly from deserialised payloads — which we then fixed by enabling RespectNullableAnnotations and validation.
The interviewer checks real-world fluency with a feature that every modern .NET codebase uses.
Green flags
[NotNullWhen].required, notnull and unconstrained T?.Red flags
! liberally.string? is a different type at run time, or confuses it with Nullable<T>.Because string? is still System.String, the compiler records nullability in metadata attributes:
[Nullable(1)] = not-null, [Nullable(2)] = maybe-null, [Nullable(0)] = oblivious. For generic types, a byte array encodes each type argument (Dictionary<string, List<string?>> → [1, 1, 1, 2]).[NullableContext(n)] on a type or method sets the default to shrink metadata.NullabilityInfoContext (.NET 6+) reads these attributes — ASP.NET Core model binding uses it to infer [Required] for non-nullable properties, which is why enabling NRT can suddenly make API fields required and return 400s.Code compiled with nullable disabled is "oblivious": its types are treated as neither nullable nor non-nullable, so no warnings appear when consuming it.
<Nullable> has four values:
| Value | Annotations (? meaningful) |
Warnings |
|---|---|---|
disable |
No | No |
annotations |
Yes | No |
warnings |
No (oblivious) | Yes |
enable |
Yes | Yes |
This split allows staged migration: first annotate public APIs (annotations), or first see warnings on existing code (warnings), then enable.
The compiler performs a data-flow pass over the method's control-flow graph, similar to definite assignment:
new are not-null; default and null literals are maybe-null; a read of a declared T? is maybe-null; a read of T is not-null.if (x != null) { A } else { B }, x is not-null in A.x.Length), the compiler assumes not-null for the rest of the path (it warned once at the dereference).this.Customer. That is a deliberate unsoundness to avoid warning noise.The analysis is intentionally unsound in several places (fields, arrays, struct defaults, default in generics) to keep false positives manageable. It finds the vast majority of bugs, not all.
T? on unconstrained T: for reference types adds nullability; for value types, nothing (not Nullable<T>). So T? FirstOrDefault<T>() returns 0 for int.where T : class → T non-nullable reference; where T : class? → any reference type.where T : notnull → non-nullable reference or value type (used by Dictionary<TKey, ...>'s key).[MaybeNull] T / [return: MaybeNull] were the pre-C# 9 way of expressing "may return default".required members and constructorsCS8618 (non-nullable field not initialised) is the most common warning in DTOs. Options, from best to worst:
required modifier (C# 11): public required string Email { get; init; } — the compiler forces object initialisers to set it, and System.Text.Json (.NET 7+) honours required during deserialisation.= string.Empty;, = [];) when empty is meaningful.= null!; — only for framework-populated properties, such as EF Core navigation properties.string Name → nvarchar(...) NOT NULL; string? Notes → nullable column. Required navigation properties are configured from annotations. Enabling NRT on an existing model can generate migrations that change column nullability — review them carefully.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes turns this off). A missing field becomes a 400 validation error instead of a null.[Required] plus ValidateDataAnnotations().ValidateOnStart() because configuration binding does not respect NRT.#nullable enable per file for gradual adoption in big projects.!. Track warning counts in CI.bool TryFind(int id, out Customer? c) and observe the warning when using c after true; add [NotNullWhen(true)].Initialize() method that sets a field and fix CS8618 with [MemberNotNull].{} into a record with a non-nullable string Name and observe it is null; then enable RespectNullableAnnotations.NullableAttribute bytes.using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace Types;
public sealed class Customer
{
public required string Name { get; init; } // must be set by initialiser
public string? MiddleName { get; init; } // optional
public string Email { get; init; } = string.Empty;
public string DisplayName =>
MiddleName is { Length: > 0 } middle ? $"{Name} {middle[0]}." : Name;
}
public sealed class CustomerRepository
{
private readonly Dictionary<int, Customer> _byId = new();
private List<Customer> _cache;
public CustomerRepository() => Reload();
[MemberNotNull(nameof(_cache))]
private void Reload() => _cache = [.. _byId.Values];
public bool TryFind(int id, [NotNullWhen(true)] out Customer? customer)
=> _byId.TryGetValue(id, out customer);
public Customer? Find(int id) => _byId.GetValueOrDefault(id);
public void Add(Customer customer)
{
ArgumentNullException.ThrowIfNull(customer); // still validate public inputs
_byId[_byId.Count + 1] = customer;
Reload();
}
}
public static class NrtDemo
{
public static void Main()
{
var repo = new CustomerRepository();
repo.Add(new Customer { Name = "Ada" });
if (repo.TryFind(1, out var c))
Console.WriteLine(c.DisplayName); // no warning: NotNullWhen(true)
Customer? maybe = repo.Find(42);
Console.WriteLine(maybe?.Name ?? "not found");
// Run-time gap: JSON can still produce null unless you opt in
var options = new JsonSerializerOptions { RespectNullableAnnotations = true };
try
{
JsonSerializer.Deserialize<Customer>("""{"Name":null}""", options);
}
catch (JsonException ex)
{
Console.WriteLine($"Rejected: {ex.Message}");
}
}
}
What happens: public methods skip ArgumentNullException.ThrowIfNull because the parameter is non-nullable.
Why it is wrong: callers without NRT, reflection, deserialisers and ! can still pass null; the NRE then happens deep inside your code.
Do instead: keep guard clauses at public boundaries and validate deserialised input.
! to silence warningsWhat happens: customer!.Address!.City everywhere after enabling NRT.
Why it is wrong: ! hides the warning without fixing anything — you have converted compile-time information into a production NRE.
Do instead: fix the flow (guards, patterns, ?? throw), annotate APIs with attributes, and require a justification comment for any remaining !.
= null! as the default fix for CS8618What happens: every DTO property becomes public string Name { get; set; } = null!;.
Why it is wrong: objects can be created without the property set, and the compiler can no longer help.
Do instead: use constructors, records, or required members; reserve null! for framework-set properties like EF navigations.
What happens: bool TryGet(int id, out Order? order) without [NotNullWhen(true)], or validation helpers without [NotNull]/[DoesNotReturn].
Why it is wrong: callers get false warnings and learn to ignore or suppress them.
Do instead: annotate helpers so the compiler understands their contract.
What happens: after enabling NRT, previously optional request fields become implicitly required and clients start getting 400 responses.
Why it is wrong: ASP.NET Core treats non-nullable reference properties as required.
Do instead: mark genuinely optional fields as string?, review the OpenAPI diff, and communicate contract changes.
What happens: a migration silently changes columns from NULL to NOT NULL (or the reverse).
Why it is wrong: the migration can fail on existing null data or loosen constraints unintentionally.
Do instead: review generated migrations and mark properties nullable where data really can be missing.
T? in generics means Nullable<T>What happens: a generic method returns T? and callers with int expect null for "not found".
Why it is wrong: for value types, unconstrained T? is just T, so "not found" returns 0.
Do instead: use a Try-pattern, a result type, or separate overloads with where T : struct / where T : class.
What happens: a request DTO with string Name arrives with Name == null because JSON omitted it.
Why it is wrong: serialisers populate objects without running your constructors or respecting annotations by default.
Do instead: use required, enable RespectNullableAnnotations (.NET 9+), and validate requests.
What happens: hundreds of warnings accumulate and nobody reads them.
Why it is wrong: real issues are hidden in the noise.
Do instead: drive the count to zero and set <WarningsAsErrors>nullable</WarningsAsErrors>.
What happens: every reference becomes ? so nothing warns.
Why it is wrong: callers must now null-check everything, and the type system carries no information.
Do instead: decide intentionally: non-nullable by default, ? only where absence is a valid state.
NRT is a static contract system; its value depends on honest annotations and treating warnings as errors.
| Layer | Strategy |
|---|---|
| API boundary (controllers, message handlers) | validate inputs; enable JSON RespectNullableAnnotations |
| Domain model | non-nullable by default, required members, no null! |
| Data access | align EF Core nullability with the schema; review migrations for unexpected NULL changes |
| Shared libraries | annotate fully; consumers rely on your metadata |
| Legacy code | enable per project or file, warnings first, then errors |
[Required] validation), so review migrations and API contracts when enabling it on an existing project.<Nullable>enable</Nullable> makes reference types non-nullable by default: string? means a value can be null and string means it can't. The compiler then warns when code might dereference null. It's purely compile-time, so nothing changes at run time. In a big codebase I migrate bottom-up: shared libraries and domain types first, because their annotations flow out to everyone else. I go project by project, or file by file with #nullable enable, and give each step a small pull request so feature work carries on. When a project is clean I make its nullable warnings errors with WarningsAsErrors=nullable, so it can't slip back. I use attributes such as [NotNullWhen] where flow analysis needs help and keep ! for real boundaries. I also watch for two things: EF Core migrations, where nullability decides whether columns are required, and public APIs, where annotations are part of the contract.
The return type is the contract, so I would make it honest. Not-found becomes Task<Order?> with nullable reference types on, so callers are forced to handle null, or a Result<Order> if there are several expected failure reasons. The two out parameters must go because async methods cannot have out parameters; their information moves into the result object. The internal List<Order> is replaced with IReadOnlyList<Order> (or a copy) so callers cannot mutate our state. C# gives us void, values, tuples, records, out/Try pattern, ref returns, IEnumerable<T>/IAsyncEnumerable<T>, and Task/ValueTask.
int? is Nullable<int>, a struct that pairs a value with a HasValue flag, so a value type can represent "no value" (a nullable database column, an optional filter). Reading .Value when it is empty throws, so I use ??, GetValueOrDefault() or if (x is int v). Operators are lifted: null + 1 is null, and comparisons like null > 0 are false. Boxing is special: a null int? boxes to a null reference and a non-null one boxes to a plain boxed int. Unlike string?, int? is a real run-time type.
How to tell it (STAR)
**S:** Our customer portal [at company] threw 500 errors for a small set of customers. **T:** I needed to fix it and find similar hidden bugs. **A:** Instead of patching one null check, I enabled nullable reference types on the domain project, triaged the warnings, found the middle-name bug and nine others, and made nullable warnings build errors. **R:** The profile errors went to zero, and [number] potential null bugs were fixed before they reached production.
Last updated 30 Oct 2026.