Hello. Nullable reference types are now enabled solution-wide and warnings fail the build, so this lesson turns that policy into a practical maintenance skill: fixing an existing code path by making its null contracts truthful and its control flow explicit.
The target is not “make the squiggles disappear.” It is to preserve useful diagnostics while ensuring that, at every dereference, the compiler and the runtime behavior agree about whether a value can be absent. This is particularly important in backend code, where an absent database row, optional request field, or partially initialized object otherwise tends to surface later as a generic NullReferenceException.
Treat each warning as a contract question
Nullable reference types add two related pieces of information to C#:
- A declaration contract:
Playershould not be null;Player?may be null. - A flow-state proof: at this specific point in code, the compiler has proved a value is either not-null or maybe-null.
A ? does not add a runtime check and it does not create a distinct runtime type. It tells the compiler, reviewers, and callers that null is a permitted outcome. Conversely, a non-nullable reference such as Achievement expresses a promise: code exposing that value will provide a real instance.
The compiler tracks that promise through branches. A direct null check changes the flow state within the safe branch.

The practical rule is:
Do not start with a preferred syntax. First decide whether
nullis valid in this part of the domain and workflow.
For example:
- A player lookup may legitimately find no player, so
Task<Player?>is honest. - An achievement code received from an external client may be missing, so
string?is honest at that boundary. - An awarded achievement inside a successful submission result should normally be non-null, because a successful result without an achievement is contradictory.
This short Microsoft Learn reading establishes the compiler model and the main repair techniques you will use.
Resolve nullable warnings - C# | Microsoft Learn
Read the relevant parts of Microsoft Learn’s “Resolve nullable warnings.” It explains nullable flow analysis as a proof system, then connects each common warning pattern to a design-level fix rather than a suppression.
Start in the “Null-state: what the compiler tracks” section. Read the overview to distinguish annotations from the compiler’s local flow analysis. Then read the “Add a null check” section, beginning with flow analysis and continuing through the examples of guard clauses, null operators, and property patterns. Focus on why only the safe branch gets a non-null proof. Next, in “Adjust annotations” and “Add a null-analysis attribute,” read the contract gap, then the NotNullWhen example and attribute list. Finally, in “Initialize non-nullable members,” read construction guidance and the alternatives that follow. Notice the warning against inventing placeholder values merely to satisfy the compiler.
Work through a realistic game-backend path
Consider a service method from the game backend. The contracts say a submitted achievement code might be absent in incoming data, a player may not exist, and a catalog lookup may not find a matching achievement.
public sealed record SubmitProgressCommand(
Guid PlayerId,
string? AchievementCode);
public interface IPlayerRepository
{
Task<Player?> FindByIdAsync(
Guid playerId,
CancellationToken cancellationToken);
}
public interface IAchievementCatalog
{
Achievement? FindByCode(string code);
}
Those signatures are plausible. The following implementation is not:
public async Task<ProgressResult> SubmitAsync(
SubmitProgressCommand command,
CancellationToken cancellationToken)
{
var code = command.AchievementCode.Trim(); // CS8602
var player = await players.FindByIdAsync(
command.PlayerId,
cancellationToken);
var achievement = catalog.FindByCode(code);
return ProgressResult.Accepted(
player.Id,
achievement.Points); // CS8602 on player and achievement
}
The compiler has identified three independently meaningful facts:
AchievementCodemay be null.FindByIdAsyncmay return null.FindByCodemay return null.
A weak fix would be to add !:
var code = command.AchievementCode!.Trim();
That removes the warning, but it has not made a missing achievement code valid. At runtime, the same input can still throw. The warning was valid; the code concealed it.
A correct repair assigns behavior to every legitimate absence case:
public async Task<ProgressResult> SubmitAsync(
SubmitProgressCommand command,
CancellationToken cancellationToken)
{
var submittedCode = command.AchievementCode;
if (string.IsNullOrWhiteSpace(submittedCode))
{
return ProgressResult.InvalidAchievementCode();
}
var player = await players.FindByIdAsync(
command.PlayerId,
cancellationToken);
if (player is null)
{
return ProgressResult.PlayerNotFound(command.PlayerId);
}
var achievement = catalog.FindByCode(submittedCode.Trim());
if (achievement is null)
{
return ProgressResult.AchievementNotFound(submittedCode);
}
return ProgressResult.Accepted(
player.Id,
achievement.Points);
}
This is not “extra defensive code” added solely for the compiler. It defines the application behavior:
| Possibly absent value | Where absence is handled | Resulting behavior |
|---|---|---|
| Achievement code | At the service entry | Reject an invalid command |
| Player | Immediately after repository lookup | Return a not-found outcome |
| Achievement | Immediately after catalog lookup | Return a specific business outcome |
After the early return for an empty or missing code, the compiler recognizes submittedCode as non-null. After each subsequent guard clause, execution can continue only with a real Player and Achievement. The final successful result therefore has a sound non-null contract.
This pattern is a strong default for backend application code: validate or branch at the point where absence becomes meaningful, then keep the successful path non-nullable.
Choose the repair that describes reality
Nullable warnings tend to fall into a few categories. The diagnostic code helps identify the location of the disagreement, but it does not choose the correct design.
| Warning family | What it usually means | Typical honest repair |
|---|---|---|
| CS8602 | A maybe-null value is being dereferenced | Guard clause, conditional behavior, or restructuring control flow |
| CS8600, CS8601, CS8603, CS8604 | A maybe-null value crosses into a non-nullable variable, return, property, or parameter | Make the contract nullable, reject or replace invalid input, or correct the producing API’s annotation |
| CS8618 | A non-nullable member is not definitely initialized | Constructor initialization, a meaningful initializer, required, or a nullable member |
| Warnings after a helper method | The helper proves something the compiler cannot infer | Add a nullable flow-analysis attribute when its contract is genuinely reusable |
1. Missing values: branch, return, or throw
If a nullable value is valid at a boundary but cannot be used by the current operation, handle it before dereferencing.
var player = await repository.FindByIdAsync(playerId, cancellationToken);
if (player is null)
{
return ProgressResult.PlayerNotFound(playerId);
}
var displayName = player.DisplayName.Trim();
In a domain or application method, a “not found” result may be appropriate. In a lower-level method where null indicates a programmer error, throwing can be appropriate:
ArgumentNullException.ThrowIfNull(configuration);
The distinction is behavioral, not syntactic:
- Return or branch when absence is an expected outcome that a caller can handle.
- Throw when the caller violated a method precondition.
- Use a non-nullable type when absence is not valid after the boundary has been crossed.
2. Null coalescing: use a real default, not a disguised error
The null-coalescing operator is useful when the fallback really is valid:
var label = player.Nickname ?? player.DisplayName;
Here, using the display name when a nickname is absent is normal product behavior.
It is usually a poor fix to write this simply to satisfy the compiler:
var code = command.AchievementCode ?? string.Empty;
An empty achievement code is not a meaningful achievement code. This moves the ambiguity downstream and makes error handling less precise. Preserve the fact that input was absent and handle it deliberately.
Similarly, ?. is appropriate when “do nothing if absent” is really intended:
logger.LogInformation(
"Optional clan name: {ClanName}",
player.Clan?.Name);
It is not appropriate when the operation fundamentally requires a clan. In that case, a guard clause makes the business rule visible.
When the compiler cannot see your proof
The compiler understands direct checks such as is null, is not null, == null, and many BCL methods such as string.IsNullOrWhiteSpace. It cannot automatically infer the semantics of your own helper methods.
Suppose the codebase has this utility:
public static bool HasText(string? value)
{
return !string.IsNullOrWhiteSpace(value);
}
This call is logically safe, but the compiler does not yet know why:
if (HasText(command.AchievementCode))
{
var normalized = command.AchievementCode.Trim(); // CS8602
}
The signature only promises a Boolean result. It does not state what that Boolean proves about value.
When this is a stable, reusable contract, annotate it:
using System.Diagnostics.CodeAnalysis;
public static bool HasText([NotNullWhen(true)] string? value)
{
return !string.IsNullOrWhiteSpace(value);
}
Now a true result proves that the argument is non-null:
if (HasText(command.AchievementCode))
{
var normalized = command.AchievementCode.Trim();
}
NotNullWhen(true) is a promise to both compiler and callers. It must match the method’s implementation. A method returning true for null would make the annotation unsound and reintroduce runtime risk under the appearance of safety.
Use these attributes sparingly:
- Prefer a direct guard clause when the logic is local and simple.
- Add an attribute when a helper method establishes a reusable null-state guarantee.
- Test the helper’s behavior, because its annotation is part of the public contract.
For a quick visual review of flow narrowing, the null-forgiving operator, and null operators, watch these selected segments.
C# Nullable reference types – No more null reference exceptions!
Watch “C# Nullable reference types – No more null reference exceptions!” by Filip Ekberg for a compact code-based demonstration of the compiler changing its view after a null check, followed by a useful warning about the null-forgiving operator.
Watch flow narrowing to see how an explicit check removes a warning only on the path where the value is known to exist. Then skip ahead to operator tradeoffs. Focus on the fact that ! changes the compiler’s assumption but does not create an object or prevent a runtime exception; compare it with the behavior of ?. and ??.
Initialization warnings are design feedback too
CS8618 often appears after nullable analysis is enabled:
public sealed class PlayerProfile
{
public string DisplayName { get; set; }
public string? Biography { get; set; }
}
Biography can honestly be absent. But DisplayName promises a string while allowing object construction to finish without one.
Do not initialize required business data with string.Empty merely to quiet CS8618. An empty display name is often just “missing” disguised as a string.
Choose one of these designs based on the invariant:
public sealed class PlayerProfile
{
public PlayerProfile(string displayName)
{
DisplayName = displayName;
}
public string DisplayName { get; }
public string? Biography { get; set; }
}
Use constructor initialization when a valid instance must always have the value.
public sealed class PlayerProfile
{
public required string DisplayName { get; init; }
public string? Biography { get; init; }
}
Use required when object initialization is the intended construction style. The next lesson will examine immutable API contracts, init accessors, records, and required in more depth. For now, the key point is simple: a non-nullable member needs a construction path that makes its promise true.
Empty collections are a notable exception because they are usually valid, fully usable values:
public List<Achievement> Achievements { get; } = [];
An empty list means “there are currently no achievements,” not “the list was never initialized.”
Do not fix a valid warning by silencing analysis
With the repository policy from the previous lesson, avoid these responses to a warning in active code:
#nullable disable
#pragma warning disable CS8602
<NoWarn>CS8602</NoWarn>
Each removes the compiler’s ability to identify a potentially unsafe dereference. They may have narrowly justified migration uses in a large legacy codebase, but they are not a repair for a code path you are actively changing.
The null-forgiving operator deserves the same skepticism:
var player = await repository.FindByIdAsync(playerId, cancellationToken);
return player!.DisplayName;
This says, “trust me,” while retaining the possibility that player is null. It is not a null check.
There are rare framework-boundary cases where ! can document a fact the compiler cannot observe. EF Core initialization of a DbSet is a common example:
public DbSet<PlayerEntity> Players { get; set; } = null!;
The ORM initializes the property, but the constructor-flow analysis cannot see that framework behavior. Such uses should be:
- Narrow: applied to one expression or member, never broadly.
- Documented by framework behavior: not merely hoped for.
- Exceptional: not the normal way application code handles nullable values.
For ordinary application logic, prefer a truthful signature, a guard clause, a meaningful default, or a precise nullability attribute.
A repeatable warning-elimination workflow
When you encounter nullable warnings in a real code review or maintenance task, use this sequence:
- Read the full diagnostic and identify the expression that is maybe-null. Do not immediately add
!,?, or a default value. - Trace the value to its source. Is it external input, an optional property, a lookup result, a framework callback, or an incorrectly annotated API?
- State the domain rule. Is null valid, invalid, or impossible by an already-enforced invariant?
- Repair the contract and control flow.
- Mark genuinely optional data nullable.
- Guard expected absence at the operation boundary.
- Make mandatory members initialized by construction.
- Correct source API annotations.
- Add nullable attributes only for real, reusable guarantees.
- Build without suppressions. A clean build should result from a clearer program, not a quieter compiler.
For the portfolio backend, apply that workflow to one narrow slice before proceeding. A useful first commit is a small Progress service path such as the example above: model lookup methods as returning Player? or Achievement? when absence is real, add explicit outcomes for those cases, and keep the accepted path fully non-nullable.
Run the repository build after the change:
dotnet build GameBackend.sln --configuration Release
Review the diff for misleading defaults such as string.Empty, unjustified !, and changed string versus string? signatures that callers have not been updated to handle.
Key takeaways
- Nullable analysis combines declared contracts such as
string?with local flow-state analysis that proves whether an expression is safe at a particular point. - A nullable warning is a design question: determine whether absence is valid, invalid, or impossible before selecting a fix.
- Guard clauses are the usual backend default: handle missing input or lookup results early, then keep the successful path non-nullable.
- Use
??and?.only when their fallback or skipped behavior is genuinely correct. - Use
NotNullWhenand related attributes to expose real null-state guarantees from reusable helpers. - Do not hide valid diagnostics with
#nullable disable, warning suppression, arbitrary placeholder values, or casual uses of!.
Next, you will use these trustworthy null contracts to model immutable API contracts and value objects with records, init-only properties, and required members.
Can't find a good explanation? Sign up and we'll make it for you
Sign up