Null checks in C# look simple. A question mark here, a coalescing operator there, and the compiler stops complaining. But many of these checks quietly swallow bugs instead of preventing them. Over time, they turn into landmines that only detonate in production under specific conditions. In this guide, we examine seven common null-checking patterns that often hide bugs, explain why they're dangerous, and show you how to fix them with clearer, more intentional code.
1. The Null-Forgiving Operator: When You Lie to the Compiler
The null-forgiving operator (!) tells the compiler, "Trust me, this value is not null." But the runtime doesn't listen to that operator — it only suppresses warnings. If the value actually is null, you get a NullReferenceException at the point of use, often far from where the assumption was made.
Consider this example: string name = GetName()!; If GetName() returns null, the compiler stays silent, but any subsequent method call on name crashes without a clear trace back to the assumption. The real bug is that GetName() might legitimately return null, and the caller should handle that case.
When It's Acceptable
Use ! only when you have an invariant that the compiler cannot prove — for example, after a manual null check in a different code path, or when you're certain due to external constraints (like a database constraint). Even then, add a comment explaining the invariant.
Better Alternatives
- Use nullable reference types with proper flow analysis. Let the compiler track null state through your code.
- Throw a meaningful exception early if the value is null, using
ArgumentNullExceptionor a custom guard clause. - Return a
Maybe<T>orOption<T>type to make null handling explicit in the signature.
The null-forgiving operator is a shortcut that trades long-term clarity for short-term silence. In most cases, it's better to restructure the code so the compiler can verify null safety on its own.
2. Coalescing Away the Problem: Default Values That Mask Failures
The null coalescing operator ?? is a handy tool: var result = value ?? defaultValue;. But when used indiscriminately, it hides the fact that value was unexpectedly null. A null might indicate a missing configuration, a failed lookup, or an uninitialized field — all of which deserve attention, not a silent default.
For example, int timeout = config.GetValue<int>("Timeout") ?? 30; silently substitutes 30 if the config key is missing. But maybe the config file should have that key, and a missing value is a deployment error. By hiding it, you make debugging harder: the system runs with a fallback value, and the root cause remains undiscovered until users report odd behavior.
When Defaults Are Appropriate
Defaults work well for optional settings where a missing value is a valid state — for instance, a logging level that defaults to "Info" if not specified. The key is to distinguish between "null means absent, and absent is okay" versus "null means something went wrong."
Fix: Fail Fast for Required Values
For values that must exist, use ?? only after logging or throwing. Better yet, validate at the boundary: var timeout = config.GetValue<int>("Timeout") ?? throw new InvalidOperationException("Timeout must be configured");. This makes the failure immediate and clear.
Another approach is to use ??= with lazy initialization only when the null state is expected and transient — for example, a cache that populates on first access. But even then, consider whether a null indicates a cache miss that should be handled explicitly.
3. Silent Null Propagation: The Dotted Chain That Breaks Quietly
The null-conditional operator ?. lets you safely access members: var street = person?.Address?.Street;. This is elegant, but it can also hide where in the chain the null occurred. If person is null, street becomes null. If Address is null, same result. You lose diagnostic information about which part of the chain failed.
In a complex expression like order?.Customer?.Profile?.Email, a null result tells you nothing about why. Was the order missing? The customer? The profile? Debugging requires adding separate checks or logging at each level.
When to Use It
Null propagation is fine for optional navigation where a null anywhere in the chain is a valid absence — for example, building a display string where any missing piece is simply omitted. But for data that must be complete, avoid chaining ?. without diagnostics.
Fix: Break the Chain with Intent
Instead of one long chain, separate the steps and check each intermediate result:
var customer = order?.Customer; if (customer == null) { /* log or handle */ return; } var profile = customer.Profile; if (profile == null) { /* log */ return; } var email = profile.Email;This adds a few lines but makes the failure point explicit. For critical paths, consider using a TryGet pattern or a custom Maybe type that carries error context.
4. Overusing Nullable Reference Types Without Proper Analysis
Nullable reference types (NRTs) are a powerful feature introduced in C# 8.0, but they can give a false sense of security. Simply annotating a property as string? doesn't ensure it's checked before use — it only enables compiler warnings. Many teams enable NRTs globally but then ignore or suppress the warnings with #nullable disable or the null-forgiving operator.
Worse, NRTs are purely compile-time annotations. The runtime doesn't enforce them. A library compiled without NRTs can pass a null to a parameter marked as non-nullable, and you'll get a null reference at runtime with no warning.
Common Pitfalls
- Marking a property as
string?but never checking for null before using it, relying on the compiler's flow analysis — which can be fooled by method calls or assignments across boundaries. - Using
MaybeNullorNotNullWhenattributes incorrectly, causing the compiler to misanalyze null state. - Forgetting that generic types like
T?behave differently depending on whether T is a value type or reference type.
Fix: Combine NRTs with Runtime Guards
Treat NRTs as a complement to runtime validation, not a replacement. At public boundaries — method parameters, API inputs, deserialization — always validate with ArgumentNullException.ThrowIfNull or similar. Use NRTs to catch internal inconsistencies during development, but don't assume they prevent all null-related bugs.
Also, configure your project to treat nullable warnings as errors. This forces the team to address every warning rather than letting them accumulate.
5. Pattern Matching with Null: The Forgotten Case
C# 7 introduced pattern matching with is expressions. A common pattern is if (obj is SomeType x) { ... }. This checks both type and null: if obj is null, the pattern doesn't match, so x is guaranteed non-null inside the block. However, developers sometimes forget this and add a redundant null check: if (obj is SomeType x && x != null). That's harmless but noisy.
The real bug arises when using the not pattern: if (obj is not null) { ... } is clear, but if (obj is not SomeType) does NOT check for null — it matches null because null is not of type SomeType. So if (obj is not SomeType) will execute the block for null values as well, which is often unintended.
Example of the Bug
if (obj is not SomeType) { Console.WriteLine("Not SomeType"); } // Also prints if obj is null!To exclude null, you need if (obj is not null && obj is not SomeType) or use the not pattern with a type check: if (obj is not SomeType && obj is not null).
Fix: Be Explicit About Null in Patterns
When using is not with a type, always add an explicit null check if you want to exclude null. Alternatively, use a switch expression with a null case: obj switch { null => ..., SomeType x => ..., _ => ... }. This makes null handling a separate, explicit branch.
Pattern matching is powerful, but its null semantics can be counterintuitive. Review your is patterns carefully, especially when using negation.
6. Relying on Default(T) for Nullable Value Types
For nullable value types like int?, default(int?) is null. But for non-nullable value types, default(int) is 0. Mixing these up leads to subtle bugs. A common mistake is using default in a generic method that should handle both reference and value types: T result = default;. For a value type, this gives a non-null default (0, false, etc.), but for a reference type with NRTs disabled, it gives null.
Another pitfall: Nullable<T>.GetValueOrDefault() returns the underlying value if not null, or the default of T (e.g., 0 for int) if null. This can hide a null state: int value = nullableInt.GetValueOrDefault(); silently converts null to 0, which might be a valid value in your domain. Then you can't distinguish between "the user entered 0" and "the user didn't enter anything."
Fix: Use Explicit Patterns
- In generics, use
T? result = default;only when T is constrained to be a nullable value type or reference type. For unconstrained generics, consider aMaybe<T>wrapper. - For nullable value types, prefer
HasValuechecks combined with the null coalescing operator only when a default is semantically correct. Otherwise, keep the nullable and handle both cases. - Avoid
GetValueOrDefault()in domain logic unless you explicitly want to treat null as the default value of the underlying type.
The default keyword is convenient but ambiguous. Make your null handling explicit so that the intent is clear to future readers.
7. Ignoring Null State After Null Coalescing Assignment
The null coalescing assignment operator ??= assigns a value only if the left-hand side is null: cache ??= LoadCache();. This is great for lazy initialization, but it can mask the fact that the left-hand side was unexpectedly null. If cache was supposed to be set earlier but wasn't, ??= silently fixes it, and the bug in the earlier code path remains hidden.
For example, consider a property that should be initialized in the constructor but isn't because of a refactoring error. Later, ??= provides a fallback, but the real issue — missing initialization — goes undetected. The application works, but only because of a safety net that shouldn't be needed.
When to Use ??=
Use ??= for genuinely optional lazy initialization where null is the expected initial state. For example, a cache that starts empty and populates on demand. But for fields that must be set before use, prefer explicit initialization or validation in the constructor.
Fix: Validate Assumptions
If you use ??= as a fallback for something that should already be set, add a log warning: cache ??= LoadCache(); could be preceded by if (cache == null) Log.Warning("Cache was unexpectedly null; reloading");. This way, you'll notice if the fallback fires more often than expected.
Alternatively, restructure the code so that the null state is impossible by the time ??= runs. Use nullable reference types to enforce that the field is non-null after initialization, and rely on ??= only for genuinely optional caches or lazy properties.
8. Putting It All Together: A Checklist for Null-Safe Code
After reviewing these seven patterns, you might feel that null handling is more nuanced than it first appears. That's because it is. The goal isn't to eliminate null entirely — that's often impractical — but to make null states explicit and handle them intentionally.
Quick Reference: Do's and Don'ts
- Don't use the null-forgiving operator as a quick fix for compiler warnings. Instead, understand why the warning exists and address the root cause.
- Do use nullable reference types, but combine them with runtime validation at boundaries.
- Don't chain null-conditional operators without considering diagnostic loss. Break the chain when you need to know which step failed.
- Do prefer explicit null checks over silent defaults for required values.
- Don't rely on
default(T)in generic code without considering value type semantics. - Do use pattern matching with care — remember that
is not SomeTypematches null. - Don't use
??=to cover up missing initialization. Log or throw if the fallback fires unexpectedly.
Next Steps for Your Codebase
Start by auditing your code for the patterns described here. Search for ! (null-forgiving), ?? used without logging, and long ?. chains. For each occurrence, ask: "Is null here a valid state or a bug?" If it's a bug, fix the root cause. If it's valid, make that intent clear with a comment or a more explicit pattern.
Consider enabling nullable reference types in your project if you haven't already, and set warnings as errors. This forces your team to confront null issues during development rather than in production. Also, adopt a consistent approach to argument validation: use ArgumentNullException.ThrowIfNull (available in .NET 6+) or a custom guard clause library.
Finally, invest in unit tests that cover null inputs. A test that passes null to each public method and verifies the expected exception or return value will catch many of the hidden bugs we've discussed. Null handling is not just a compiler concern — it's a design decision that affects reliability and maintainability. By being deliberate about when and how you check for null, you'll write code that is easier to reason about and less prone to surprises.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!