Knowing a value might be null (lesson 050) is only half the story — this is the toolkit for actually handling it, cleanly.
In the previous lesson, you saw how Nullable Reference Types tell the compiler — and you — exactly which values might be null. But knowing something might be null doesn't automatically make your code nice to read. Left to the old habits, you end up with a staircase of nested if checks:
string city;
if (customer != null)
{
if (customer.Address != null)
{
if (customer.Address.City != null)
{
city = customer.Address.City;
}
else { city = "Unknown"; }
}
else { city = "Unknown"; }
}
else { city = "Unknown"; }
That's eleven lines to answer one question: "what city, or 'Unknown' if any link in the chain is missing?" C# gives you a small set of operators that collapse this entire staircase into a single, readable line.
In this lesson, you'll learn the null-conditional operator (?.), the null-coalescing operator (??), null-coalescing assignment (??=), the null-forgiving operator (!) and when it's a code smell, and the everyday patterns for writing code that handles null gracefully instead of fearfully.
Null-safe programming is writing code that handles "this might not have a value" gracefully and concisely, instead of either crashing on it or drowning in defensive if statements. C# gives you four small operators purpose-built for this:
?. — "if it's not null, go ahead — otherwise, stop and give me null."?? — "if that's null, use this fallback instead."??= — "if this variable is null, assign it — otherwise, leave it alone."! — "I know better than you, compiler — trust me, this isn't null." (Use sparingly — more on this below.)The null-conditional operator (?. for members, ?[...] for indexers) short-circuits a member access or invocation: if the operand to its left evaluates to null, the entire expression evaluates to null immediately, without evaluating anything to the right. The null-coalescing operator (??) evaluates its left operand, and if that's null, evaluates and returns its right operand instead — otherwise it returns the left operand unchanged. Null-coalescing assignment (??=, C# 8) combines the two: it assigns the right-hand value to the left-hand variable only if the variable currently holds null. The null-forgiving operator (!, a compile-time-only postfix operator, sometimes called the "damnit operator") tells the compiler's nullable flow analysis to treat an expression as non-null, suppressing any warning it would otherwise produce — without changing anything about the actual value at runtime.
Once nullable reference types make "this could be null" visible everywhere, the honest reaction is to check for it — a lot. Without dedicated syntax, every optional value in a chain needs its own if, and every default value needs its own else. The logic you actually care about (what city, what price, what fallback) gets buried under scaffolding that exists purely to avoid crashing.
Developers needed a way to express "walk this chain, but bail out safely the moment something is missing" and "use this value, or fall back to that one" as single, readable expressions — not multi-line control-flow blocks that obscure the actual intent.
C# answers this with a small family of operators that read almost like plain English once you know them: customer?.Address?.City ?? "Unknown" says exactly what it means — "the city, if we can reach it; otherwise, 'Unknown'" — in one line instead of eleven.
if (customer != null) if (customer.Address != null) if (customer.Address.City != null) city = customer.Address.City; else city = "Unknown"; else city = "Unknown";else city = "Unknown";
string city = customer?.Address?.City ?? "Unknown";
One line. Same behavior: stop at the first null, fall back to "Unknown".
string? city = customer?.Address?.City;
// If customer is null → the whole expression short-circuits to null immediately.
// If customer.Address is null → same thing, short-circuits to null.
// Only if BOTH are non-null does .City actually get read.
a?.b?.c?.d stops at the first null link.list?[0]) and method calls (logger?.Log("msg")).string city = customer?.Address?.City ?? "Unknown";
// If the left side is null (for any reason), use "Unknown" instead.
// Result type is non-nullable: city is guaranteed a real string here.
?? combines beautifully with ?. — one supplies the safe chain, the other supplies the fallback.private List<string>? _cache;
public List<string> GetCache()
{
_cache ??= new List<string>(); // only creates a new list if _cache is still null
return _cache;
}
if (_cache == null) { _cache = new List<string>(); } — but one line.public string GetTrimmedName(Customer customer)
{
// We validated customer.Name is set upstream, but the compiler can't see that.
return customer.Name!.Trim();
}
NullReferenceException — you've just told the compiler to stop warning you about it.public class Address
{
public string? City { get; set; }
}
public class Customer
{
public string Name { get; set; } = "";
public Address? Address { get; set; }
}
Customer? customer = FindCustomer(101); // might return null if not found
// ── Safe navigation + fallback, combined ──
string city = customer?.Address?.City ?? "Unknown";
// ── Safe method call — does nothing if customer is null ──
customer?.SendNotification();
// ── Safe indexer access ──
int? firstOrderId = orders?[0]?.Id;
Console.WriteLine(city); // "Unknown" if customer, Address, or City was missing anywhere in the chain
Code → Meaning → Result: Each ?. stops the chain the instant it hits null, so city ends up as "Unknown" the moment any of customer, customer.Address, or customer.Address.City is missing — no exception, no nested if, one readable line.
A user-profile lookup service that needs a display name, falling through several optional sources, is a textbook fit for this toolkit:
public class UserProfile
{
public string? DisplayName { get; set; }
public string? NickName { get; set; }
public string Email { get; set; } = "";
}
public class ProfileService
{
private readonly Dictionary<int, UserProfile> _profiles = new();
private List<string>? _recentSearches; // lazily created
public string GetGreetingName(int userId)
{
UserProfile? profile = _profiles.GetValueOrDefault(userId); // might be null — no such user
// Try DisplayName, then NickName, then fall back to the part of the email before '@'.
return profile?.DisplayName
?? profile?.NickName
?? profile?.Email.Split('@')[0]
?? "Guest";
}
public void TrackSearch(string term)
{
_recentSearches ??= new List<string>(); // lazily initialize on first use
_recentSearches.Add(term);
}
}
Notice how ?? can be chained: it tries DisplayName first, falls through to NickName, then to a derived value from Email, and finally to a hardcoded default — each step only evaluated if the previous one came up null. This reads as a prioritized list of fallbacks, which is exactly the shape of the actual business rule.
Imagine walking through a sequence of doors to reach a room: front door → hallway → office door → the object on the desk. ?. is like checking each door before you try to walk through it — if any door isn't there, you stop right where you are instead of walking into a wall.
?? is what you do once you've stopped: "if I couldn't reach the desk, just tell me the lobby is empty" — a pre-agreed fallback answer instead of standing there with nothing to report. !, on the other hand, is you insisting to a safety inspector "that door is definitely there, don't bother checking" — and if you're wrong, you still walk into the wall; you've just disabled the warning sign.
Unlike the null-forgiving operator, ?. and ?? are real runtime behavior, not just compiler annotations. Roughly, customer?.Address?.City compiles down to something equivalent to:
Address? tempAddress = (customer == null) ? null : customer.Address;
string? tempCity = (tempAddress == null) ? null : tempAddress.City;
The compiler also evaluates customer exactly once, even if it appears multiple times in the chain, and short-circuits the rest of the expression the instant a null is found — so a long chain doesn't waste time evaluating steps it will never use.
The null-forgiving operator produces zero IL of its own — it exists purely to change what the compiler's nullable flow analysis reports. At runtime, customer.Name!.Trim() and customer.Name.Trim() are identical — same bytecode, same behavior, same crash if Name really is null.
?? vs ?: (the ternary operator) — related, but not the samex ?? y specifically checks whether x is null. The ternary condition ? a : b checks any boolean condition. x ?? y is shorthand for x != null ? x : y — but only for the null case; if your fallback logic depends on something other than nullness, you need the full ternary or an if.
!) doesn't check anything — it silences the checkBeginners sometimes read value! as "assert that value is not null, and throw if it is" — like a runtime guard. It isn't. It performs no check. It's purely a note to the compiler: "stop warning me here." If value actually is null, value!.SomeMethod() throws exactly the same NullReferenceException it always would have — you've just made the compiler quiet about the risk beforehand.
?. on a method call still runs the method — it just guards the call itselflogger?.Log("started") means "only call Log if logger isn't null" — it does not mean "call Log, and if the message argument is null, skip it." The null-conditional only protects the thing immediately to its left.
! as the default fix for every nullable warning Slapping ! on everything the compiler complains about, without actually verifying the value is safe:
var city = customer.Address!.City!; // "make the warnings go away," but is it actually true?
Ask why the compiler thinks it might be null. Usually ?. with a sensible fallback, or a genuine validation check, is the honest fix — ! should be reserved for cases where you have real, external knowledge the compiler simply can't see (e.g. right after a framework guarantees non-null via an attribute the compiler doesn't understand). If you find yourself using ! often, treat it as a code smell worth investigating.
?. where a missing value should actually be an error Using ?. to silently swallow a null that actually represents a bug:
// If _paymentGateway should NEVER be null once the app has started, hiding it with ?. delays the failure and makes it harder to diagnose.
_paymentGateway?.Charge(amount);
For values that represent genuine invariants (a required dependency that must exist), fail loudly and early — e.g. via constructor validation (ArgumentNullException.ThrowIfNull) — rather than silently no-op'ing with ?..
??= only checks for null, not "empty" or "default"string? name = "";
name ??= "Guest"; // does NOT run — name is "" (empty string), not null, so it's left unchanged
If you need "null or empty," combine it with string.IsNullOrEmpty and an explicit assignment, rather than assuming ??= covers that case too.
?. and ?? whenever a value is legitimately optional and you have (or can define) a sensible fallback or "do nothing" behavior.??= for lazy initialization — "create it once, on first use."! rarely, and only when you have information the compiler can't — right after a framework contract you trust, or in test code setting up a known-valid state.?./??. If a null means "something has gone wrong" — validate and throw, don't silently chain past it.
?. and ?? generate real runtime null-checks — ! generates nothing at all.?. safely navigates a chain of possibly-null references, stopping at the first null.?? supplies a fallback value when the left side is null, and is lazy — the right side only runs if needed.??= assigns a value only if the variable is currently null — perfect for lazy initialization.! silences a compiler warning without adding any actual check — use it sparingly, and only when you genuinely know more than the compiler does.You've seen how ?., ??, ??=, and ! collapse defensive null-checking into readable expressions. Let's check that it stuck.
1. What does string city = customer?.Address?.City ?? "Unknown"; evaluate to if customer is not null but customer.Address is null?
Correct: B
Why B is correct: The first ?. passes (customer isn't null), but the second ?. encounters a null Address and short-circuits the whole chain to null. The ?? then catches that null and supplies "Unknown".
Why A is incorrect: That's exactly the crash ?. is designed to prevent — it stops safely instead of throwing.
Why C is incorrect: The chain does become null internally, but ?? "Unknown" catches that null and replaces it, so the final assigned value is "Unknown", not null.
Why D is incorrect: This is valid, well-formed C#.
Reinforcement: A null anywhere in a ?. chain short-circuits the whole expression to null, and ?? can then supply a real fallback.
2. What does the null-forgiving operator (!) actually do at runtime?
Correct: C
Why C is correct: The null-forgiving operator generates no IL of its own. It exists purely to tell the compiler's nullable flow analysis "treat this as non-null," which suppresses the warning — but the actual runtime value and behavior are completely unchanged.
Why A is incorrect: It performs no check whatsoever, so it can't throw anything on its own — if the value is genuinely null, a later dereference still throws the same NullReferenceException it always would have.
Why B is incorrect: It doesn't touch the value at all — it's not a conversion, just a compiler annotation.
Why D is incorrect: There's no exception handling involved; it's a pure compile-time suppression.
Reinforcement: ! silences the compiler, not the risk. Use it only when you genuinely know the compiler is wrong.
3. A method has a required dependency, _paymentGateway, that must never be null once the application has started — if it's null, that indicates a serious configuration bug. Which approach best fits null-safe programming principles?
Correct: B
Why B is correct: When null represents an actual bug rather than a normal case, the right move is to fail fast and loudly at the point of construction — not to hide the problem with ?. or paper over the warning with !. This gives you a clear, early, diagnosable error instead of a silent no-op or a scattered crash later.
Why A is incorrect: Silently skipping the charge hides a serious bug — the payment simply never happens, with no error or log to explain why.
Why C is incorrect: Sprinkling ! everywhere doesn't fix the underlying issue — it just tells the compiler to stop warning about a real risk, without any actual guarantee.
Why D is incorrect: Swallowing exceptions hides critical failures rather than surfacing them, making the bug far harder to find.
Reinforcement: Null-safe programming isn't about hiding every null — it's about handling expected nulls gracefully and letting genuine invariant violations fail loudly.
4. What is the output of the following code?
string? name = "";
name ??= "Guest";
Console.WriteLine($"[{name}]");
Correct: B
Why B is correct: ??= only assigns when the left-hand side is null. An empty string "" is not null — it's a real, valid (if empty) string value — so the assignment is skipped and name stays "".
Why A is incorrect: That would only happen if name started out null, not empty.
Why C is incorrect: No dereference of a null value happens here — just an assignment check.
Why D is incorrect: name was never null to begin with, and ??= doesn't introduce null.
Reinforcement: ??= (and ??) check specifically for null — not for empty strings, zero, or other "falsy" values.
5. Why is reaching for the null-forgiving operator (!) on almost every nullable warning generally considered a code smell?
Correct: C
Why C is correct: ! has zero runtime effect — it only tells the compiler to stop flagging a possible null. Overusing it as a blanket fix for warnings means you've turned off the exact safety net nullable reference types exist to provide, often without actually confirming the value is safe.
Why A is incorrect: ! has no runtime cost at all — it generates no code.
Why B is incorrect: Readability isn't the core issue — the real problem is the false sense of safety it creates.
Why D is incorrect: There's no such restriction; ! can be used as many times as you write it, which is exactly why overuse is easy to fall into.
Reinforcement: Treat frequent use of ! as a signal to investigate — usually a proper ?./?? pattern or an upstream validation fix is the honest solution.
You now have the everyday toolkit for null-safe C# — enough to replace most defensive if staircases with a single, readable line.
dotnetmadeeasy.com — Learn C# and .NET, the right way.