This post introduces the null-conditional assignment operator, a new feature of C# 14 that allows you to write clean and terse code.
When looking at the C# 14 updates coming out, I discovered a feature and thought, “How did this not already exist?” If you’ve been using C#'s null-conditional operators (?. and ?[]) for years like I have, you’ll love the support for null-conditional assignments.
Our Long Null Journey
It’s been a long journey to improve how C# developers work with null, the billion-dollar mistake.
C# 2 kicked things off with nullable value types (like int? and bool?) because sometimes you need to know if someone didn’t enter a number versus entering zero.
With C# 6, we got null-conditional operators (?. and ?[]), letting us chain through potentially null objects without writing novels. C# 7 gave us pattern matching with is null checks that read like English.
C# 8 came out with nullable reference types, the null-coalescing operator (??=), and the null-forgiving operator (!) for when you think you know better than the compiler. And, of course, C# 9 rounded it out with is not null because is null was feeling lonely.
For me, the null-conditional operators from C# 6 were a game-changer. Instead of doom-checking each level of potential nulls, I can just chain it.
// The old way
string? city = null;
if (customer != null && customer.Address != null)
{
city = customer.Address.City;
}
// The new way
string? city = customer?.Address?.City;
This was great for reading values. However, we could never use the same trick for writing values. Enter C# 14.
The Problem It Solves
How many times have you written code like this?
if (customer != null)
customer.Order = GetOrder(customer.Id)
If you’re anything like me, this pattern is burned into your memory. I type it without thinking.
But we already have a perfectly good “do this thing if not null” operator. We just couldn’t use it for assignments.
Weird, yes? We could read through each null conditionally but not write through it. Every little assignment needed its own little null check guard.
The C# 14 Solution
C# 14 lets you write this instead:
customer?.Order = GetOrder(customer.Id);
That’s it! Clean, readable and the intent is obvious: “If the customer isn’t null, assign the current order to it.”
The semantics are exactly what you’d hope for: the right-hand side (GetOrder(id)) only runs if the left-hand side isn’t null. If customer is null, nothing happens—no assignment, no method call, and no exceptions.
: Like any language feature, this is a tool and not a hammer for every nail. There are times when explicit null checks are actually clearer.
For example, don’t hide business logic. If null requires specific handling, explicit checks are much clearer.
// Less clear - what should happen if account is null?
account?.Balance += deposit;
// Better when null is exceptional
if (account is null)
throw new InvalidOperationException("Cannot deposit to null account");
account.Balance += deposit;
Watch out for side effects! Remember, the entire right-hand side is skipped if the left is null.
// We don't call GetNextId() if the record is null
record?.Id = GetNextId();
Compound Assignments
The real power in this improvement shows up when you use null-conditional assignments with compound assignment operators.
// Before C# 14
if (account != null)
{
account.Balance += deposit;
}
// C# 14
account?.Balance += deposit;
This works with all compound assignment operators: +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>= and ??=.
Check out this shopping cart example:
public class ShoppingCart
{
public decimal Subtotal { get; set; }
public decimal Tax { get; set; }
}
public void ApplyDiscount(ShoppingCart? cart, decimal discountAmount)
{
// Only apply discount if cart exists
cart?.Subtotal -= discountAmount;
// Recalculate tax based on new subtotal
cart?.Tax = cart.Subtotal * 0.08m;
}
Notice the improvements: no nested ifs, no ceremony, just the expected “if it’s there, update it” logic. Finally.
When This Feature Shines
Let’s take a look at some brief real-world scenarios where null-conditional assignments are genuinely useful.
Optional Dependencies
In modern apps, you have services everywhere—like logging, telemetry, caching and so on—and it seems half of them are optional depending on what features are enabled.
public class TelemetryService
{
private ILogger? _logger;
private IMetricsCollector? _metrics;
public void RecordEvent(string eventName, Dictionary<string, object> properties)
{
// Log only if logger is configured
_logger?.LogInformation("Event recorded: {EventName}", eventName);
// Track metrics only if collector is available
_metrics?.EventCount += 1;
}
}
With this improvement, our code doesn’t get buried under a mountain of if (_logger != null) checks. The dependency is handled right where you use it, which means the happy path (the service is there) stays front and center.
This is huge with classes that have multiple optional dependencies. Traditional null checks create a ton of noise that obscures what your code actually does.
Event Handlers with Optional Subscribers
When you’re working with events, subscribers are optional by nature. It’s like me watching a football game: sometimes I’m listening and sometimes I’m not. And that’s fine.
public class ProgressTracker
{
public IProgress<int>? Progress { get; set; }
private int _currentStep;
private int _totalSteps;
public void AdvanceProgress()
{
_currentStep++;
var percentComplete = (_currentStep * 100) / _totalSteps;
// Report only if someone is listening
Progress?.Report(percentComplete);
}
}
With this improvement, publishers don’t need to defensively write null checks before every notification. This is great for library code or reusable components where you have zero control over whether consumers attach handlers.
Plus, it’s self-documenting. Progress?.Report() clearly says “if anyone cares, report progress.”
Conditional Updates with Fluent APIs
Builder patterns and fluent APIs are obsessed with optional configuration. Sometimes you’ve set up all the pieces, and sometimes just a few.
public class ApplicationBuilder
{
public DatabaseConfiguration? Database { get; set; }
public ApiConfiguration? Api { get; set; }
public void ApplyProduction()
{
// Safely configure only what exists
Database?.ConnectionString = Environment.GetEnvironmentVariable("DB_PROD");
Database?.CommandTimeout += TimeSpan.FromSeconds(30);
Api?.RateLimitPerMinute = 1000;
Api?.EnableCaching = true;
}
}
With this example, configuration methods can be flexible without requiring everything to exist first. This is perfect for plugin architectures or systems where features come and go. You can write configuration code that gracefully handles a mix of initialized components without the spaghetti code.
Collection Operations
We consistently work with collections that might not be initialized yet. Think of when you’re doing lazy initialization for performance reasons.
public class CacheManager
{
private List<string>? _recentItems;
private Dictionary<string, object>? _settings;
public void RecordAccess(string item)
{
// Add to recent items if cache is initialized
_recentItems?.Add(item);
// Update access count
if (_settings?.ContainsKey("accessCount") == true)
{
_settings["accessCount"] = ((int)_settings["accessCount"]) + 1;
}
}
public void UpdateTheme(string theme)
{
// Safe dictionary assignment
_settings?["theme"] = theme;
}
}
Nice, eh? You can now work with collections that might not exist yet without checking if they exist first. This is great for performance-sensitive code where you want to defer allocating collections until you actually need them … but you also want clean code. We can now keep the lazy initialization pattern without our code looking like a mess.
One Gotcha: No ++ or –-
Don’t kill the messenger: You can’t use increment (++) or decrement (--) operators with null-conditional access.
// Ope
counter?.Value++;
If you want this pattern, do the traditional null check or use the compound assignment form.
counter?.Value += 1;
Captain Obvious says: ++ and -- are a read and write operation rolled into one. And that’s where semantics can get really weird with null-conditional operators. If counter is null, what should counter?.Value++ return? Null? The pre-increment value? The post-increment value that never computed? Instead of confusing everyone, it just isn’t supported.
A ‘Putting It All Together’ Example: A Configuration System
Let’s put this all together with an example. Let’s build a super-exciting configuration system that applies environment-specific overrides to application settings.
public class AppSettings
{
public DatabaseConfig? Database { get; set; }
public ApiConfig? Api { get; set; }
public LoggingConfig? Logging { get; set; }
public CacheConfig? Cache { get; set; }
}
public class DatabaseConfig
{
public string? ConnectionString { get; set; }
public int CommandTimeout { get; set; }
public bool EnableRetry { get; set; }
}
public class ApiConfig
{
public string? Endpoint { get; set; }
public TimeSpan Timeout { get; set; }
public int MaxRetries { get; set; }
}
public interface IAppEnvironment
{
string? GetVariable(string name);
bool IsDevelopment();
}
public class ConfigurationUpdater
{
public void ApplyEnvironmentOverrides(AppSettings? settings, IAppEnvironment env)
{
// Database overrides - only if database config exists
settings?.Database?.ConnectionString = env.GetVariable("DB_CONNECTION");
settings?.Database?.CommandTimeout = 60;
settings?.Database?.EnableRetry = true;
// API configuration - compound assignments work too
settings?.Api?.Endpoint = env.GetVariable("API_URL");
settings?.Api?.Timeout += TimeSpan.FromSeconds(30);
settings?.Api?.MaxRetries = 5;
// Logging adjustments
settings?.Logging!.Level = LogLevel.Information;
settings?.Logging!.EnableConsole = env.IsDevelopment();
// Cache settings
settings?.Cache!.ExpirationMinutes += 15;
}
public void ApplyDevelopmentDefaults(AppSettings? settings)
{
// Development-specific configuration
settings?.Database?.EnableRetry = false; // Fail fast in dev
settings?.Api?.Timeout = TimeSpan.FromSeconds(5); // Shorter timeouts
settings?.Logging?.Level = LogLevel.Debug;
}
}
This allows us to be modular. Not every app needs every config section. A simple POC might only touch database config, while our behemoth needs everything.
It also allows us to be optional by nature. The settings object itself might be null during early startup and individual sections might not be wired up yet. It be like that sometimes.
Compare it to the old way. To avert your eyes, I’ll use a snippet.
// The verbose alternative (please don't do this)
if (settings != null)
{
if (settings.Database != null)
{
settings.Database.ConnectionString = env.GetVariable("DB_CONNECTION");
settings.Database.CommandTimeout = 60;
}
if (settings.Api != null)
{
settings.Api.Endpoint = env.GetVariable("API_URL");
settings.Api.Timeout += TimeSpan.FromSeconds(30);
}
}
That is a lot of ceremony just for “configure what exists.” With null-conditional assignments, we get clean code that focuses on what we’re configuring instead of whether we can configure it.
Wrapping Up
Unless you’re a language designer, C# 14’s null-conditional assignment won’t blow your mind. However, it will make your code clearer and easier to write. It takes a pattern we’ve all typed a thousand times and finally makes it as concise as it should have been all along.
So next time you catch yourself typing if (x != null) { x.Property = value; }, know there’s a better way.
What patterns will you use with null-conditional assignments? Let me know in the comments, and happy coding!







