C# & .NET (C# 14, .NET 10 LTS) Module 9 – Null safety
Nullability attributes, guards and migration
Describe null contracts with NotNullWhen and other nullability attributes, refuse null arguments with ThrowIfNull, and move C# code to nullable file by file.
What you will learn
- Express nullability contracts with NotNullWhen, MaybeNullWhen and MemberNotNull
- Validate arguments with ArgumentNullException.ThrowIfNull and the related guard helpers
- Plan the migration of an existing code base to nullable reference types, file by file
- Predict which nullability promises the compiler checks inside a method and which it trusts
- Measure the warnings of a migration in each nullable context and choose the order of the work
Before you start
What you’ll learn · Before you start
What you will learn
- Express nullability contracts with NotNullWhen, MaybeNullWhen and MemberNotNull
- Validate arguments with ArgumentNullException.ThrowIfNull and the related guard helpers
- Plan the migration of an existing code base to nullable reference types, file by file
- Predict which nullability promises the compiler checks inside a method and which it trusts
- Measure the warnings of a migration in each nullable context and choose the order of the work
Before you start
On this page
if (timetable.TryFind("NDLS", out Station? first)) checks the result, and the next line, first.Name, still
draws warning CS8602. The compiler does not look inside TryFind, so it cannot know that true means a station was
found, and one attribute on the parameter tells it.
You will write such contracts with nullability attributes, refuse bad arguments at run time with the guard helpers, and plan how an existing code base moves to nullable reference types while other work goes on.
Nullability attributes describe what a method promises
stations has two Try methods that do the same work. Only TryFindStation puts [NotNullWhen(true)] on its out
parameter:
using System.Diagnostics.CodeAnalysis;
// Two Try methods that do the same work. Only TryFindStation tells the compiler what true means.
var timetable = new Timetable();
if (timetable.TryFind("NDLS", out Station? first))
{
Console.WriteLine(first.Name);
}
if (timetable.TryFindStation("SBC", out Station? second))
{
Console.WriteLine(second.Name);
}
if (!timetable.TryFindStation("XYZ", out Station? third))
{
Console.WriteLine($"XYZ: not found, station is null: {third is null}");
}
class Station
{
public Station(string name) => Name = name;
public string Name { get; }
}
class Timetable
{
private readonly Dictionary<string, Station> _byCode = new()
{
["NDLS"] = new Station("New Delhi"),
["SBC"] = new Station("KSR Bengaluru"),
};
public bool TryFind(string code, out Station? station) => _byCode.TryGetValue(code, out station);
public bool TryFindStation(string code, [NotNullWhen(true)] out Station? station) =>
_byCode.TryGetValue(code, out station);
} from session import run
# stations calls two Try methods that differ only in [NotNullWhen(true)]. It is built once and run once.
run("""
cd stations
dotnet build -v q
dotnet bin/Debug/net10.0/App.dll
""") Output
$ cd stations
$ dotnet build -v q
stations/Program.cs(8,23): warning CS8602: Dereference of a possibly null reference. [stations/App.csproj]
Build succeeded.
stations/Program.cs(8,23): warning CS8602: Dereference of a possibly null reference. [stations/App.csproj]
1 Warning(s)
0 Error(s)
Time Elapsed …
$ dotnet bin/Debug/net10.0/App.dll
New Delhi
KSR Bengaluru
XYZ: not found, station is null: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stations_session.py
The build drew one warning, CS8602 at line 8, the read after TryFind. Step through what the compiler knew after each
call:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- TryFind returns true. timetable.TryFind("NDLS", out Station? first) finds New Delhi and returns true. Its signature says only that first is a Station?.
- The compiler does not know. The analysis does not look inside TryFind, so for the compiler first is still maybe-null, and first.Name drew CS8602 at line 8.
- TryFindStation returns true. TryFindStation("SBC", out Station? second) does the same work, but its parameter carries [NotNullWhen(true)].
- The attribute is the promise. The attribute says that true means second is not null, so inside the if the compiler treats it as not-null, and second.Name needed no check.
- false keeps the old state. For XYZ the method returned false. The attribute says nothing about false, so third stays maybe-null, and the run confirmed that it was null.
nullability attribute: An attribute from System.Diagnostics.CodeAnalysis, such as NotNullWhen or MemberNotNull, that tells the compiler a null-state promise that ? alone cannot express, such as what holds when a method returns true. live in System.Diagnostics.CodeAnalysis and add what
? alone cannot say: a promise that depends on the result, or on the state of an object. Microsoft’s reference
groups them by what they promise:
| Group | Attributes | What they tell the compiler |
|---|---|---|
| Preconditions | AllowNull, DisallowNull |
what a caller may pass in |
| Postconditions | MaybeNull, NotNull |
what holds when the method returns |
| Conditional postconditions | MaybeNullWhen, NotNullWhen, NotNullIfNotNull |
what holds for one result or one argument |
| Helper methods | MemberNotNull, MemberNotNullWhen |
which fields a helper sets |
| Unreachable code | DoesNotReturn, DoesNotReturnIf |
that a call never comes back |
The compiler reads an attribute from the method’s signature, never from its body, so it works for methods in other
assemblies just as well. That is how the annotations of the .NET libraries reach your code: Dictionary.TryGetValue
and string.IsNullOrEmpty already work with flow analysis, and your own Try methods can do the same with one
attribute each. Beside NotNullWhen, the two you will meet most are MemberNotNull, for a private helper that sets
fields, and MaybeNullWhen(false), for the out value of a generic Try method.
Checkpoint
Check: Nullability attributes and Try methods
Guard clauses refuse null at run time, where annotations only warn
Nullability attributes cost nothing at run time, and so they stop nothing at run time either: a caller can pass
null!, compile with nullable switched off, or call from another .NET language. A public method that must not get
null checks its arguments as well. guards.cs sends five orders to Shipping.Label, a public method that starts
with three guard clause: A check at the start of a method that returns or throws at once when an input is bad, so the code after it can assume the input is good and stays unindented. More in “if, switch statements and switch expressions”:
// Guard clauses at a public method: each helper names the argument it refused.
var shipping = new Shipping();
var asha = new Customer("Asha Rao");
Report("a null order", () => shipping.Label(null!, "Pune"));
Report("an order with no customer", () => shipping.Label(new Order("A-101", null), "Pune"));
Report("an empty city", () => shipping.Label(new Order("A-102", asha), ""));
Report("a blank city", () => shipping.Label(new Order("A-103", asha), " "));
Report("a complete order", () => shipping.Label(new Order("A-104", asha), "Pune"));
static void Report(string what, Func<string> label)
{
try
{
Console.WriteLine($"{what}: {label()}");
}
catch (ArgumentException e)
{
Console.WriteLine($"{what}: {e.GetType().Name}: {e.Message}");
}
}
class Customer
{
public Customer(string name) => Name = name;
public string Name { get; }
}
class Order
{
public Order(string id, Customer? customer)
{
Id = id;
Customer = customer;
}
public string Id { get; }
public Customer? Customer { get; }
}
class Shipping
{
public string Label(Order order, string city)
{
ArgumentNullException.ThrowIfNull(order);
ArgumentNullException.ThrowIfNull(order.Customer);
ArgumentException.ThrowIfNullOrWhiteSpace(city);
return $"{order.Id} for {order.Customer.Name}, {city}";
}
} Output
a null order: ArgumentNullException: Value cannot be null. (Parameter 'order') an order with no customer: ArgumentNullException: Value cannot be null. (Parameter 'order.Customer') an empty city: ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'city') a blank city: ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'city') a complete order: A-104 for Asha Rao, Pune
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run guards.cs
ArgumentNullException.ThrowIfNull(order.Customer) named the parameter order.Customer, although the call passed no
name. The helper’s second parameter carries [CallerArgumentExpression], so the compiler fills in the text of the
argument. ArgumentException.ThrowIfNullOrWhiteSpace(city) refused the empty and the blank city with the same
message.
| Helper | Throws for | Since |
|---|---|---|
ArgumentNullException.ThrowIfNull |
null |
.NET 6 |
ArgumentException.ThrowIfNullOrEmpty |
null or "" |
.NET 7 |
ArgumentException.ThrowIfNullOrWhiteSpace |
null, "" or only white space |
.NET 8 |
After a guard, flow analysis knows the value is there: order.Customer.Name on the last line needed no check.
Key idea
Annotations warn the callers you compile; guards stop the ones you do not. A public method has both: the annotations say what it accepts, and the guards enforce it when the program runs.
Checkpoint
Check: Guard clauses and the argument they name
Migration moves a code base to nullable one file at a time
Turning <Nullable>enable</Nullable> on for an old project can bring hundreds of warnings at once. Microsoft’s
nullable migration: Moving code written without nullable reference types to <Nullable>enable</Nullable> in steps: file by file, from the leaf types outward, until no #nullable directive is left. guide splits the work into steps instead, and since nullable warnings
do not stop a build, the code keeps building and shipping between them. Walk through the phases:
- Phase 1
Choose a default for the project project file
Set <Nullable>enable</Nullable> and put #nullable disable at the top of each file not migrated yet, so new files start nullable-aware; or keep the project disabled and add #nullable enable to each file as you migrate it.
- Phase 2
Start with the leaf types order of work
Begin with the types that depend on no other type of yours, then the types that use them, and so on outward. A caller taken after the types it calls has its warnings fixed once, against declarations that will not change again.
- Phase 3
Warnings or annotations first two passes
Fix the dereferences that the warnings context reports while the types stay as they are, or annotate the public API in the annotations context first and fix the warnings after it. Each pass then changes only one kind of thing.
- Phase 4
Fix the callers callers
Each annotation moves warnings to the code that uses the value. Fix them with checks, ? and the attributes of this lesson, and keep ! for the few places where you can show a value is there.
- Phase 5
Finish the migration end state
The project file keeps <Nullable>enable</Nullable>, and the code keeps no #nullable directive at all. Any = null! or default! that only quieted a warning during the work gets a real value, or its member becomes nullable.
The timeline has 5 events, from Phase 1 to Phase 5.
Whichever order you choose, the work ends in one place: enable in the project file and not one #nullable directive
in the code. The phases also explain why migrations stall: every type you annotate moves its warnings to the code that
calls it.
Leaf types first
Start with the types that depend on nothing else of yours. Their warnings stay inside them, and once they are annotated, the warnings they cause in their callers are the next files to take.
Checkpoint
Check: Planning a migration to nullable
Each attribute with the warning it removes, compiled Pro: opens with a Pro, Premium or Ultimate pass
Every attribute changes what the compiler reports, at the call site or inside the method. Each one below was built on the .NET 10 SDK, so you can see the warnings it removes and the one promise the compiler checks for you.
In this part:
- MemberNotNull removes four CS8618 warnings, measured
- Four more attributes, and the promise the compiler checks
- MaybeNullWhen for generic Try methods
- Choosing the attribute: what you want to say, and where this lesson said it
6 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Guards in depth: what they name, when they fire, where they belong Pro: opens with a Pro, Premium or Ultimate pass
A guard has two jobs: to stop a bad value before any work is done, and to name it so that the report leads straight to the bug. Both are measured below, together with the places where a guard pays for itself and where it does not.
In this part:
- What ParamName holds, measured
- A guard at the entry, against a crash deep inside
- Where guards belong: a decision table
2 runnable examples (C#) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A migration measured: one library in every nullable context Pro: opens with a Pro, Premium or Ultimate pass
Advice about migration is easy to give and hard to judge. This part builds one small library in every nullable context and through two steps of its migration, and counts the warnings at each step.
In this part:
- The library, built six ways, with every warning counted
- What the counts say about the order of work
9 runnable examples (C#, CSPROJ, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: annotate a name cache
cache treats nullable warnings as errors. NameCache.cs is the helper to annotate and guard, and Program.cs holds
the call sites and six checks; leave it as it is. As it starts, the build fails with five errors:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>
</Project> using System.Diagnostics.CodeAnalysis;
// Practice: annotate NameCache and add guard clauses, without changing what it does, until the project builds
// with nullable warnings as errors and Program.cs prints "6 of 6 checks pass".
public class NameCache
{
private Dictionary<string, string> _names;
public NameCache() => Initialize();
public NameCache(Dictionary<string, string> seed)
{
Initialize();
foreach (var (code, name) in seed)
{
_names[code] = name;
}
}
public int Hits { get; private set; }
public bool TryGet(string code, out string? name) => _names.TryGetValue(code, out name);
public string GetOrAdd(string code, Func<string, string> load)
{
if (_names.TryGetValue(code, out string? name))
{
Hits++;
return name;
}
name = load(code);
_names[code] = name;
return name;
}
private void Initialize()
{
_names = [];
Hits = 0;
}
} // The call sites and checks of the practice. Leave this file as it is.
var cache = new NameCache(new() { ["P-1"] = "Masala tea", ["P-2"] = "Filter coffee" });
(string Check, Func<string> Run, string Expected)[] checks =
[
("TryGet finds P-1", () => cache.TryGet("P-1", out string? name) ? $"{name.Length} characters" : "missing", "10 characters"),
("TryGet misses P-9", () => cache.TryGet("P-9", out string? name) ? name : "missing", "missing"),
("GetOrAdd loads P-3 once", () => $"{cache.GetOrAdd("P-3", Load)} / {cache.GetOrAdd("P-3", Load)} / hits {cache.Hits}", "Lemon soda / Lemon soda / hits 1"),
("a null code", () => cache.GetOrAdd(null!, Load), "throws ArgumentNullException for code"),
("a blank code", () => cache.GetOrAdd(" ", Load), "throws ArgumentException for code"),
("a null loader", () => cache.GetOrAdd("P-4", null!), "throws ArgumentNullException for load"),
];
int passed = 0;
foreach (var (check, run, expected) in checks)
{
string got;
try
{
got = run();
}
catch (ArgumentException e)
{
got = $"throws {e.GetType().Name} for {e.ParamName}";
}
catch (Exception e)
{
got = $"throws {e.GetType().Name}";
}
bool ok = got == expected;
if (ok)
{
passed++;
}
Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;
static string Load(string code) => code == "P-3" ? "Lemon soda" : "Unknown"; from session import run
# The practice project as it starts: built with nullable warnings as errors. Its errors are sorted, each shown once,
# because the compiler can report them in a different order from one build to the next.
run("""
cd cache
dotnet build -v q | grep -E "error CS|Error\\(s\\)" | sort -u
""") Output
$ cd cache
$ dotnet build -v q | grep -E "error CS|Error\(s\)" | sort -u
5 Error(s)
cache/NameCache.cs(11,12): error CS8618: Non-nullable field '_names' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable. [cache/App.csproj]
cache/NameCache.cs(16,13): error CS8602: Dereference of a possibly null reference. [cache/App.csproj]
cache/NameCache.cs(9,12): error CS8618: Non-nullable field '_names' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable. [cache/App.csproj]
cache/Program.cs(6,75): error CS8602: Dereference of a possibly null reference. [cache/App.csproj]
cache/Program.cs(7,33): error CS8603: Possible null reference return. [cache/App.csproj]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cache_session.py
Make cache build and pass its checks
Copy the cache folder. Tell the compiler which field Initialize sets (the helper-methods row of the table of
attributes) and what true from TryGet promises, and add guards to GetOrAdd for a missing or blank code and a
missing loader. Run dotnet build until it reports 0 errors,
then dotnet run until it prints 6 of 6 checks pass.
Then try the self-check, which covers the attributes, the guards and the migration plan and picks the topic you have answered least well first.
Practice that adapts to you
Practise this lesson
9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · Why validate arguments at run time when nullable reference types already warn?
The warnings exist only at compile time, and only for code compiled with nullable reference types on. A caller in a
project with nullable switched off sees no warning, a caller can pass null!, and code in another .NET language or
reached through reflection is not checked at all. The method itself still runs whatever arrives. A guard clause such
as ArgumentNullException.ThrowIfNull(order) stops a null at the first line, before any work is done, and names
the argument in the exception. Annotations and guards answer different questions: the annotation tells callers what
is allowed, and the guard enforces it.
-
Fresher to mid
Callers of your method bool TryGetZone(string pin, out Zone? zone) check zone for null again after a true result, because the compiler warns CS8602 otherwise. How do you change the API so that callers need no extra check, and how do you make sure the new promise is true?
-
Senior to staff
A 40-project solution written before C# 8 is to move to nullable reference types without stopping feature work. Lay out the plan: the order of projects, the project and file settings, how you measure progress, and when nullable warnings become errors.
-
Mid to senior
A colleague asks you to delete the ArgumentNullException.ThrowIfNull calls in a library, because nullable reference types already prevent nulls. Which guards would you keep, which would you remove, and why?
Key takeaways
- Nullability attributes from
System.Diagnostics.CodeAnalysistell the compiler what?cannot say, such as[NotNullWhen(true)]on theoutparameter of a Try method, so its callers need no extra check. - Guards such as
ArgumentNullException.ThrowIfNullstopnullwhen the program runs, name the argument throughCallerArgumentExpression, and let flow analysis treat the value as not-null afterwards. - A migration goes file by file from the leaf types outward, in the warnings or the annotations context first, and
ends with
<Nullable>enable</Nullable>and no#nullabledirectives.
Terms in this lesson
- nullability attribute
- An attribute from System.Diagnostics.CodeAnalysis, such as NotNullWhen or MemberNotNull, that tells the compiler a null-state promise that ? alone cannot express, such as what holds when a method returns true.
- nullable migration
- Moving code written without nullable reference types to <Nullable>enable</Nullable> in steps: file by file, from the leaf types outward, until no #nullable directive is left.
References
- Attributes for null-state static analysis (Microsoft Learn)
- ArgumentNullException.ThrowIfNull method (Microsoft Learn)
- ArgumentException.ThrowIfNullOrEmpty method (Microsoft Learn)
- ArgumentException.ThrowIfNullOrWhiteSpace method (Microsoft Learn)
- Caller information attributes: argument expressions (Microsoft Learn)
- Nullable migration strategies (Microsoft Learn)
- Nullable reference type warnings (CS8762, CS8618) (Microsoft Learn)
- Nullable reference types (C# reference) (Microsoft Learn)
- Nullable reference types (C# fundamentals) (Microsoft Learn)
- The history of C# (Microsoft Learn)
- Unity Manual: C# compiler (Unity Technologies)
Report a problem with this lesson
Kept only in this browser. Your Learn progress