Advanced nullable reference types: code review of common misuse, nullable attributes, generics and framework boundaries.
Nullable reference types are compile-time flow analysis, not a run-time guarantee, so every ! and = default! is a promise the compiler cannot check. In review I remove ! that hides real nulls, replace = default! with required members or constructor parameters, and teach the compiler my helpers' contracts with attributes: [NotNullWhen(true)] on Try methods, [MemberNotNull] on initialisers, [NotNullIfNotNull], [DoesNotReturn] on throw helpers. For generics, T? on an unconstrained T means "may be default". At boundaries the compiler cannot see (JSON, EF Core queries, reflection, old libraries) I validate at run time, because a non-nullable string can still hold null there. With NRT on, EF Core also maps non-nullable reference properties to NOT NULL columns, so annotations change your schema.
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = default!; // silence CS8618
public string Email { get; set; } = default!;
public Address Address { get; set; } = default!;
public string? Phone { get; set; }
}
public bool TryGetCustomer(int id, out Customer? customer) { ... }
if (repo.TryGetCustomer(id, out var c))
SendEmail(c!.Email); // ! because the compiler warns
var dto = JsonSerializer.Deserialize<CustomerDto>(json)!;
var city = dto.Address!.City!.ToUpper();
T? First<T>(IEnumerable<T> items) => items.FirstOrDefault();
The project enabled nullable reference types but then switched them off line by line:
= default! on properties tells the compiler "this is never null" while initialising it to null. Any new Customer() or deserialised object can have Name == null and the compiler won't warn — the exact bug NRT was meant to catch.! sprinkled at call sites (c!.Email, dto.Address!.City!) suppresses warnings without proving anything. Each ! is a potential NullReferenceException the compiler would have found.TryGetCustomer(out Customer? customer) returns true with a non-null customer, but without annotations the compiler assumes customer may still be null after true — so developers add !.Deserialize(...)! — deserialisation can return null (for JSON null) and can leave non-nullable properties null if fields are missing; ! hides both.T? in First<T> doesn't mean Nullable<T> for value types: for int, T? is just int, returning 0 for "not found".string Name (non-nullable) maps to a NOT NULL column, string? Phone to nullable — so annotations aren't cosmetic; wrong ones change the schema or hide data problems.Initialise honestly. Use constructors, required members, or real defaults instead of default!:
public sealed class Customer
{
private Customer() { } // for EF Core materialisation
public Customer(string name, EmailAddress email, Address address) { Name = name; Email = email; Address = address; }
public int Id { get; private set; }
public string Name { get; private set; } = null!; // EF sets it; acceptable, documented exception
public EmailAddress Email { get; private set; } = null!;
public Address Address { get; private set; } = null!;
public string? Phone { get; private set; }
}
public sealed record CustomerDto
{
public required string Name { get; init; }
public required string Email { get; init; }
public AddressDto? Address { get; init; } // honest: may be missing
}
null! remains only where a framework (EF Core) guarantees initialisation, ideally behind private setters/constructors — not on public DTOs.
Teach the compiler with attributes:
public bool TryGetCustomer(int id, [NotNullWhen(true)] out Customer? customer) { ... }
if (repo.TryGetCustomer(id, out var c)) SendEmail(c.Email); // no ! needed
[MemberNotNull(nameof(_connection))] private void EnsureConnected() { _connection ??= Open(); }
public static string? Normalise([NotNullIfNotNull(nameof(input))] string? input) => input?.Trim();
[DoesNotReturn] static void Fail(string message) => throw new InvalidOperationException(message);
Handle deserialisation properly:
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { RespectNullableAnnotations = true, RespectRequiredConstructorParameters = true }; // .NET 9
var dto = JsonSerializer.Deserialize<CustomerDto>(json, options) ?? throw new BadRequestException("Body required");
Plus validation (required, data annotations/FluentValidation), and mapping Address to a domain object only after checking it.
Generics: be explicit about intent:
static T? FirstOrNull<T>(IEnumerable<T> items) where T : class => items.FirstOrDefault();
static T? FirstOrNull<T>(IEnumerable<T> items, int _ = 0) where T : struct => items.Cast<T?>().FirstOrDefault();
// or a Try pattern: bool TryFirst<T>(IEnumerable<T> items, [MaybeNullWhen(false)] out T value)
Process guardrails:
<Nullable>enable</Nullable> + <WarningsAsErrors>nullable</WarningsAsErrors>.! needs a justification comment; prefer fixing the flow (guard clauses, ?? throw, patterns).ArgumentNullException.ThrowIfNull at public boundaries — annotations are compile-time only.After a team "enabled NRT" by adding = default! and ! everywhere, NullReferenceExceptions in Application Insights didn't drop at all. Reviewing the top NREs showed most were in exactly those suppressed places (deserialised DTOs with missing fields, Try methods). Adding [NotNullWhen(true)], required on DTOs, RespectNullableAnnotations, and removing ~300 ! operators (with real fixes) cut NREs by over 80% within two releases.
The interviewer wants to see whether you use NRT as a real safety tool or just silence it.
Green flags
! and = default! smells and explains the run-time reality.[NotNullWhen], [MemberNotNull], [DoesNotReturn] and when to use them.T? on unconstrained generics.Red flags
required exists or what it changes.! and default! doThe null-forgiving operator changes only the compiler's null-state for that expression to "not-null". It emits no IL and performs no check. default! on a non-nullable reference property initialises it to null while telling the compiler it's not null — the worst of both worlds.
| Attribute | Applies to | Meaning |
|---|---|---|
[NotNullWhen(bool)] |
out/ref params |
Not null when the method returns the given bool |
[MaybeNullWhen(bool)] |
out params (generic) |
May be null/default when returning the given bool |
[NotNull] |
params/returns/fields | Not null after the call (even if declared nullable) |
[MaybeNull] |
returns/fields (generic) | May be null/default |
[AllowNull] / [DisallowNull] |
setters/params | Input contracts differing from the declared type |
[NotNullIfNotNull(param)] |
returns | Not null if the named parameter isn't null |
[MemberNotNull(names)] / [MemberNotNullWhen(bool, names)] |
methods/properties | Named members are not null after the call / when returning bool |
[DoesNotReturn] / [DoesNotReturnIf(bool)] |
methods/params | Code after the call is unreachable (throw helpers, asserts) |
T?T?: nullable for reference types, unchanged for value types.where T : class → T? is a nullable reference.where T : struct → T? is Nullable<T>.where T : notnull → T can't be a nullable type argument.[MaybeNull] T (pre-C# 9 style) or T? returns for "default if not found" APIs.NOT NULL) columns and required relationships.null! initialised (EF sets them when loaded) but may be null if not included — a genuine gap; consider not exposing required navigations as non-nullable if they're often not loaded, or always projecting.required/null! patterns with care.System.Text.Json ignores nullability: missing properties remain default (null).RespectNullableAnnotations throws when null is read/written for non-nullable properties; RespectRequiredConstructorParameters enforces constructor parameters.required keyword members are enforced by STJ (.NET 7+).! occurrences and #nullable disable regions in CI.<Nullable>enable</Nullable> per project, starting with leaf/domain projects.[NotNullWhen(true)] from a Try method and observe warnings at call sites; add it back.{} into a DTO with required vs default! properties.RespectNullableAnnotations and deserialise {"name": null}.FirstOrNull<T> with unconstrained T? and call it with int.using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
#nullable enable
// BEFORE (as found in review)
public sealed class CustomerDtoBefore
{
public string Reference { get; set; } = default!; // lies: starts as null
public string? Notes { get; set; }
}
// AFTER
public sealed class CustomerDto
{
public required string Reference { get; init; } // must be supplied; STJ enforces it
public string? Notes { get; init; } // null is a valid state
}
public sealed class CustomerRepository
{
private readonly Dictionary<int, CustomerDto> _store = new();
private List<int> _recent;
public CustomerRepository() => Reset();
[MemberNotNull(nameof(_recent))] // compiler knows _recent is set
private void Reset() => _recent = [];
public bool TryGet(int id, [NotNullWhen(true)] out CustomerDto? customer)
=> _store.TryGetValue(id, out customer);
public void Add(int id, CustomerDto customer)
{
ArgumentNullException.ThrowIfNull(customer); // still validate public inputs
_store[id] = customer;
_recent.Add(id);
}
[return: NotNullIfNotNull(nameof(fallback))]
public string? NotesOr(int id, string? fallback)
=> TryGet(id, out var c) ? c.Notes ?? fallback : fallback;
}
public static class Throw
{
[DoesNotReturn]
public static void NotFound(int id) => throw new KeyNotFoundException($"Customer {id} not found");
}
public static class Program
{
static string RequireReference(CustomerRepository repo, int id)
{
if (!repo.TryGet(id, out var customer))
Throw.NotFound(id); // [DoesNotReturn]: flow analysis stops here
return customer.Reference; // no warning, no '!'
}
public static void Main()
{
var repo = new CustomerRepository();
repo.Add(1, new CustomerDto { Reference = "C-001" });
if (repo.TryGet(1, out var customer))
Console.WriteLine(customer.Reference.Length); // NotNullWhen(true): no warning
string notes = repo.NotesOr(1, "none"); // non-null because fallback is non-null
Console.WriteLine(notes);
Console.WriteLine(RequireReference(repo, 1));
try
{
JsonSerializer.Deserialize<CustomerDto>("{}");
}
catch (JsonException ex)
{
Console.WriteLine(ex.Message); // missing required property 'Reference'
}
}
}
= default! on DTO and entity propertiesWhat happens: used to silence CS8618 everywhere.
Why it is wrong: objects can exist with null values the compiler believes are impossible.
Do instead: constructors, required, real defaults; null! only for framework-initialised members.
! as a habitWhat happens: null-forgiving operators added wherever warnings appear.
Why it is wrong: converts compile-time warnings into runtime NREs.
Do instead: fix the flow; annotate helpers; justify remaining !.
What happens: bool TryX(out T? value) without [NotNullWhen(true)].
Why it is wrong: false warnings that train developers to use !.
Do instead: add the attribute.
What happens: Deserialize<T>(json)! and assuming properties are non-null.
Why it is wrong: JSON can be null or missing fields.
Do instead: check results, use required, RespectNullableAnnotations and validation.
T? in genericsWhat happens: expecting null for value types from unconstrained T?.
Why it is wrong: returns default (e.g. 0).
Do instead: constrained overloads or Try patterns.
What happens: changing annotations without reviewing migrations. Why it is wrong: columns change nullability; migrations fail on existing data. Do instead: review migrations; align annotations with real data.
What happens: removing ThrowIfNull because parameters are non-nullable.
Why it is wrong: external callers can still pass null.
Do instead: keep guards at public boundaries.
What happens: hundreds of nullable warnings ignored. Why it is wrong: real issues hidden. Do instead: treat nullable warnings as errors once clean.
Nullable reference types are a documentation system the compiler can check. Their value depends on honesty: one ! in a widely used API spreads false confidence to every caller.
| Situation | Senior choice |
|---|---|
| Value must exist from creation | constructor parameter or required |
| Value legitimately absent | T? and handle it |
| Helper method decides null state | nullable attributes, not ! |
| Data from outside the process | run-time validation at the edge |
| Large legacy codebase | per-file #nullable enable, public APIs first, warnings as errors when clean |
I also point out that nullable warnings are cheap bug reports: every one fixed before production is a NullReferenceException that never pages anyone. Track the warning count in CI and only let it go down.
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>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.
An init accessor (C# 9) lets a property be set only during object creation: in the constructor, an object initialiser or a with expression, and never afterwards. A required member (C# 11) forces every caller to set it in the object initialiser, checked by the compiler at each new. Together they give immutable objects with named, mandatory initialisation and no long constructors. A constructor marked [SetsRequiredMembers] tells the compiler it sets everything, so callers using it skip the check. System.Text.Json (.NET 7+) honours required and throws if the property is missing. Choose a constructor instead when you need validation across properties, because object initialisers run after the constructor finishes.
How to tell it (STAR)
**Situation:** after we enabled nullable reference types in our [order API], the team cleared [400] warnings mainly with `!`. **Task:** when billing started failing for [Partner B] I was asked to find the cause and stop it recurring. **Action:** I traced the failing order to a payload missing a field we had declared non-nullable with `default!`. I replaced those properties with `required`, turned on nullable-aware JSON validation, and led a clean-up that replaced `!` with nullable attributes or proper checks. **Result:** bad payloads were rejected with a clear 400 at the edge, billing errors stopped, and the `!` count dropped from [400] to [12], each one documented.
Last updated 30 Oct 2026.