Your C# Nullable Warning Might Be a Design Question

A green build can hide an unanswered question.
Consider this code:
var customer = await repository.FindByIdAsync(customerId);
await emailService.SendAsync(
customer!.Email,
"Your application has been received.");
That ! is small. The assumption behind it is not.
What guarantees that the customer exists?
Perhaps an earlier operation checked. Perhaps the database usually contains the record. Perhaps the method contract does not accurately describe what the implementation returns.
The compiler cannot work out all of that context. But the warning gives us a useful opportunity to examine it.
That is what makes nullable reference types interesting to me. They are not just about avoiding NullReferenceException. They help make assumptions visible while we can still do something about them.
What nullable reference types actually do
With nullable reference types enabled, C# lets us express whether a reference is intended to contain null:
string displayName = "Babatunde";
string? middleName = null;
The compiler uses these annotations alongside its analysis of control flow to identify potentially unsafe operations. A variable declared as nullable can still be safely accessed when the compiler can establish that it is not null at that point.
public static int GetNameLength(string? name)
{
if (name is null)
{
return 0;
}
return name.Length;
}
After the guard clause, the compiler knows that name is not null.
You can enable nullable analysis in a project file:
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
But there is an important limitation: nullable reference types provide compile time analysis. They do not introduce a different runtime type for string?, and they do not automatically validate every value entering your application.
That distinction matters when working with external input, existing code, and integrations.
What ! does, and what it does not do
The null forgiving operator changes the compiler’s interpretation of an expression.
It does not check the value at runtime. It does not create a missing object. It does not throw a more helpful exception if your assumption is wrong.
public static int GetLength(string? value)
{
return value!.Length;
}
This suppresses the nullable warning. Calling it with null can still produce a NullReferenceException.
Compare that with an actual runtime check:
public static int GetLength(string? value)
{
ArgumentNullException.ThrowIfNull(value);
return value.Length;
}
The distinction is simple:
value!tells the compiler to trust you.- A guard checks whether that trust is justified.
Using ! is not automatically wrong. Sometimes you have a guarantee that the compiler cannot infer. But it should represent a specific, explainable guarantee, not a convenient way to stop a warning. Microsoft describes its effect as limited to the compiler’s static analysis, with no runtime effect.
Start with an honest method contract
Consider a customer lookup:
public interface ICustomerRepository
{
Task<Customer> FindByIdAsync(
Guid customerId,
CancellationToken cancellationToken);
}
This signature communicates that a successful completion returns a customer rather than null.
But what happens when the record does not exist?
If the implementation returns null, the contract is misleading. A caller should not need to inspect the repository implementation to discover that absence is possible.
If “not found” is an ordinary result, express it:
public interface ICustomerRepository
{
Task<Customer?> FindByIdAsync(
Guid customerId,
CancellationToken cancellationToken);
}
Now the caller can make a deliberate decision:
app.MapGet(
"/customers/{id:guid}",
async (
Guid id,
ICustomerRepository repository,
CancellationToken cancellationToken) =>
{
var customer = await repository.FindByIdAsync(
id,
cancellationToken);
if (customer is null)
{
return Results.NotFound();
}
return Results.Ok(customer);
});
This example treats a missing customer as an expected lookup outcome.
That is a design choice, not a universal rule. Another operation might require the customer to exist and treat absence as an error.
The important point is that the signature should communicate the possibility clearly. Nullable annotations are intended to express whether null is allowed or expected.
Not every null means the same thing
A nullable reference tells us that absence is possible. It does not tell us what absence means.
Consider these three cases:
An optional value
A customer does not provide a middle name.
public sealed record CustomerProfile(
string FirstName,
string? MiddleName,
string LastName);
Here, null can be a normal part of the model.
A missing resource
A request refers to a customer identifier that does not exist.
The application must decide how that outcome should be represented to its caller.
A broken invariant
A disbursement reaches a stage where a verified beneficiary is required, but no beneficiary is available.
In this example, null represents a violated expectation rather than an ordinary optional field.
These situations should not receive the same treatment simply because all three involve null.
Before adding a check, ask:
“What does absence mean at this point in the workflow?”
That question often tells you more than “How do I remove this warning?”
A fallback can hide the problem too
The null coalescing operator is useful:
var displayName = customer.PreferredName ?? customer.FirstName;
The right operand is used when the left operand is null.
But a fallback is still a decision about meaning.
Consider:
var beneficiaryAccount =
request.BeneficiaryAccount ?? string.Empty;
The warning may disappear, but have we made the application more correct?
For this illustrative payment workflow, an empty account number does not solve the absence of a required account number. It simply changes the shape of the invalid value.
If the operation requires a value, make that requirement explicit:
public static string RequireBeneficiaryAccount(
string? accountNumber)
{
if (string.IsNullOrWhiteSpace(accountNumber))
{
throw new ArgumentException(
"A beneficiary account is required.",
nameof(accountNumber));
}
return accountNumber;
}
This checks presence only. It does not establish that the account exists, belongs to the intended recipient, or satisfies provider requirements.
Those are separate validation responsibilities.
A useful distinction is:
- Nullability expresses whether a value may be absent.
- Validation establishes whether a supplied value is acceptable.
- Business rules establish whether it is appropriate for the operation.
Do not ask one of those mechanisms to do the work of all three.
Protect the model, not just the calling code
If a domain object requires a value, consider enforcing that requirement when the object is created:
public sealed class Customer
{
public Guid Id { get; }
public string Email { get; }
public Customer(Guid id, string email)
{
if (id == Guid.Empty)
{
throw new ArgumentException(
"A customer identifier is required.",
nameof(id));
}
if (string.IsNullOrWhiteSpace(email))
{
throw new ArgumentException(
"An email address is required.",
nameof(email));
}
Id = id;
Email = email;
}
}
For objects created through this constructor, the model enforces a basic requirement: the email cannot be null, empty, or whitespace.
It does not establish that the address is deliverable or even fully validate its format.
That narrower guarantee is still valuable because it is explicit.
Nullable annotations and runtime guards serve different purposes. The annotations communicate intent to the compiler and callers. The guards enforce the relevant condition when the code executes. Nullable analysis alone does not add runtime enforcement.
Review the decision, not just the warning count
When a nullable warning appears, I find these questions more useful than immediately reaching for !:
- Can this value genuinely be missing?
- If it is missing, is that expected or exceptional?
- Does the method signature communicate that possibility?
- Is a fallback meaningful, or does it conceal invalid data?
- Where should the requirement be enforced?
- If I suppress the warning, what concrete guarantee am I relying on?
The compiler’s analysis has limits. It does not understand every business rule or eliminate the need for testing. Its warnings aim to reduce the likelihood of null reference failures, not prove that the entire application is correct.
But it can identify a place where your code and your intentions do not quite line up.
That is worth investigating.
The best outcome is not merely a quieter build.
It is code that makes the next developer ask fewer questions about what you assumed.
Comments
Join the discussionNo comments yet. Be the first to comment.



