You've written this code a thousand times. You have a nullable reference, you need to set a property on it, and so you wrap the assignment in a null check. It's three lines where one would do, and after writing it for the hundredth time in a codebase, it stops feeling like safety and starts feeling like noise.
C# 14 finally closes this gap. The ?. and ?[] operators can now appear on the left-hand side of an assignment. It's a small change to the language grammar, but it removes a surprisingly common source of boilerplate -- and the subtle bugs that creep in when developers skip the null check because they're tired of writing it.
The before and after
Here's the pattern we've all been writing since nullable reference types became the norm:
// Bad — verbose null check for a simple assignment
if (customer is not null)
{
customer.Order = GetCurrentOrder();
}
With C# 14, this collapses to a single expression:
// Good — null-conditional assignment
customer?.Order = GetCurrentOrder();
The semantics are identical. If customer is null, the right-hand side is never evaluated. GetCurrentOrder() never runs. No exception, no wasted work.
This isn't just cosmetic. When the right-hand side has side effects -- a database call, an API request, an expensive computation -- short-circuit evaluation matters. The old if block made it obvious that the method call was guarded. The new syntax preserves that guarantee in a form that's easier to scan.
It works with indexers too
The ?[] operator gets the same treatment:
string[]? tags = GetTagsOrNull();
tags?[0] = "primary";
If tags is null, nothing happens. No IndexOutOfRangeException, no NullReferenceException. The assignment simply doesn't execute.
This is particularly handy when working with optional data structures that you might receive from an external API or a deserialiser:
public class ImportService(ILogger<ImportService> logger)
{
public void ApplyDefaults(ImportResult? result)
{
result?.Warnings?[0] = "Review import before publishing";
result?.Metadata?.CreatedBy = Environment.UserName;
result?.Metadata?.ImportedAt = DateTimeOffset.UtcNow;
}
}
Every line is independently guarded. If result is null, none of the assignments run. If result exists but Warnings is null, only that line is skipped.
Compound assignment operators
Null-conditional assignment works with compound operators like +=, -=, *=, &=, and the rest:
counter?.Value += 1;
summary?.TotalBytes -= freedBytes;
options?.Flags |= FeatureFlags.Verbose;
This is especially useful for event handler registration, which was one of the primary motivations behind the feature. UI code in particular benefits:
public void Initialise(IDashboardViewModel? viewModel)
{
viewModel?.PropertyChanged += OnPropertyChanged;
viewModel?.RefreshRequested += async (s, e) => await LoadDataAsync();
}
Before C# 14, attaching an event handler to a nullable object required either an explicit null check or suppressing warnings with the ! operator. Neither option was satisfying.
Chaining through deep hierarchies
Where this feature really starts to pay for itself is in deeply nested object graphs. Consider a configuration object that might be partially populated:
public void ApplyTenantOverrides(AppConfig? config, TenantSettings tenant)
{
config?.Database?.ConnectionPool?.MaxSize = tenant.MaxConnections;
config?.Logging?.Sinks?.Console?.MinimumLevel = tenant.LogLevel;
config?.Features?.Analytics?.SamplingRate = tenant.SamplingRate;
}
Each ?. in the chain independently guards against null. If config is null, nothing runs. If config.Database is null, the ConnectionPool access is skipped. The chain short-circuits at the first null it encounters.
The equivalent code without null-conditional assignment is painful:
// Bad — nested null checks for deep assignment
if (config is not null)
{
if (config.Database is not null)
{
if (config.Database.ConnectionPool is not null)
{
config.Database.ConnectionPool.MaxSize = tenant.MaxConnections;
}
}
}
That's nine lines replacing one. Multiply it across three properties and you have a method that's mostly null-checking ceremony.
The null-coalescing compound: ?. meets ??=
One combination worth highlighting is null-conditional access with null-coalescing assignment:
cache?.ExpireAfter ??= TimeSpan.FromMinutes(30);
This reads as: "If cache is not null and cache.ExpireAfter is null, assign a default value." It's two guards in a single expression, and the compiler handles the evaluation order correctly.
The expanded equivalent is:
if (cache is not null)
{
if (cache.ExpireAfter is null)
{
cache.ExpireAfter = TimeSpan.FromMinutes(30);
}
}
This pattern is ideal for applying defaults to optional configuration objects where you want to fill in missing values without overwriting anything the caller has already set.
What you can't do
The language designers were deliberate about what's allowed and what isn't. Understanding the boundaries helps you avoid frustration.
No increment or decrement
The ++ and -- operators are not supported:
counter?.Value++; // Compiler error
--counter?.Value; // Compiler error
The language design meeting explicitly considered and rejected this. The workaround is straightforward:
counter?.Value += 1; // This works
No deconstruction assignment
You can't use null-conditional access on the left side of a deconstructing assignment:
(a?.X, b?.Y) = (1, 2); // Compiler error
If you need this, split it into separate statements:
a?.X = 1;
b?.Y = 2;
No ref assignment
Ref assignment through a conditional access isn't permitted:
ref int x = ref obj?.Field; // Compiler error
This restriction exists because the only scenario where you'd conditionally access a ref variable is through a ref field in a ref struct, and ref structs can't be used in nullable value types.
Value types as receivers
Null-conditional assignment doesn't work when the receiver of the ?. is a value type. This falls into two cases:
// Non-nullable value type — ?. isn't valid here anyway
MyStruct s = new();
s?.Value = 42; // Compiler error
// Nullable value type — the underlying value isn't a variable
MyStruct? s = new();
s?.Value = 42; // Compiler error — can't assign through Nullable<T>.Value
With Nullable<T>, the Value property returns a copy, not a reference to the stored struct. Assigning to it would silently do nothing useful, so the compiler rightfully blocks it.
Common pitfalls
Assuming the right-hand side always runs. The short-circuit evaluation means that any method call on the right side of the = might never execute. If that method has important side effects beyond producing a value, you have a logic bug:
// Risky — AuditLog.Record() won't fire if session is null
session?.LastAction = AuditLog.Record("user-login");
If the audit log entry must be recorded regardless of whether session exists, extract the call:
// Good — audit always happens
var entry = AuditLog.Record("user-login");
session?.LastAction = entry;
Mixing ?. with regular . in a chain. Be precise about which parts of the chain can actually be null:
// This will throw if config is non-null but config.Database is null
config?.Database.ConnectionPool.MaxSize = 100;
Every nullable link in the chain needs its own ?.. Don't rely on a single ?. at the start to guard the entire expression.
Overusing it to silence warnings. Null-conditional assignment is not a substitute for fixing your nullability model. If an object should never be null at a given point in your code, an explicit null check with a meaningful exception is better than silently skipping the assignment:
// Bad — hides a bug if currentUser should never be null here
currentUser?.Email = newEmail;
// Good — makes the contract explicit
if (currentUser is null) throw new InvalidOperationException("No authenticated user");
currentUser.Email = newEmail;
Under the hood
The compiler lowers a?.b = c into a straightforward conditional. When used as a statement:
// What you write
customer?.Order = GetCurrentOrder();
// What the compiler emits (conceptually)
if (customer is not null)
{
customer.Order = GetCurrentOrder();
}
When the result is used as an expression, the lowering wraps it in a ternary:
// What you write
var result = customer?.Order = GetCurrentOrder();
// What the compiler emits (conceptually)
var result = customer is null
? (Order?)null
: (customer.Order = GetCurrentOrder());
The receiver is only evaluated once in both cases, so there's no risk of double evaluation even with property accessors that have side effects.
When to reach for it
Null-conditional assignment shines in specific scenarios:
- Event handler registration on nullable UI components or view models
- Applying defaults to optional configuration or settings objects using
??= - Guarded updates to properties on objects received from external sources (APIs, deserialisers, user input)
- Deeply nested graphs where multiple levels might independently be null
It's less appropriate when:
- The object should always exist and a null indicates a bug -- throw instead
- The right-hand side has side effects that must happen regardless of the null state
- You need the result of the assignment for further logic (the nullable return type can complicate downstream code)
Summary
?.and?[]can now appear on the left side of=,+=,-=, and other compound operators- The right-hand side is only evaluated if the receiver is non-null -- genuine short-circuit semantics
- Chaining works across multiple levels:
a?.b?.c = valueshort-circuits at the first null - Increment (
++) and decrement (--) are explicitly not supported -- use+= 1instead - Deconstruction assignment and ref assignment through
?.are also off-limits - It combines naturally with
??=for applying defaults to partially-initialised objects - Don't use it to paper over nullability bugs -- if something shouldn't be null, throw