Mert Özen Further With Every Line
Backend C# Versions — #05

What's New in C# 14: Extension Members, field and Null-Conditional Assignment

Mert Özen Nov 7, 2026 8 min 5 views
What's New in C# 14: Extension Members, field and Null-Conditional Assignment

C# 14 next to its C# 10 and C# 13 equivalents: null-conditional assignment skipping the right-hand side, extension properties, static extension members and extension operators, the field keyword, arrays converting to spans, nameof(List<>), lambda parameter modifiers, partial constructors/events and compound assignment operators.

Version note: This post covers the language features that came with C# 14; the code was compiled against C# 14 / .NET 10 with the .NET 10 SDK and run.

Guess First

With C# 14 the ?. operator can move to the left-hand side of an assignment too. Below we look up two customers; one is found, the other isn't. Then we write the last order total to both. How many times does CalculateTotal run? How many lines do you see on the screen?

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Customer? ada = FindCustomer("ada@example.com");
Customer? nobody = FindCustomer("nobody@example.com");

ada?.LastOrderTotal = CalculateTotal("ada");
nobody?.LastOrderTotal = CalculateTotal("nobody");

Console.WriteLine(ada?.LastOrderTotal);

static Customer? FindCustomer(string email) =>
    email == "ada@example.com" ? new Customer { Email = email } : null;

static decimal CalculateTotal(string who)
{
    Console.WriteLine($"calculating for {who}");
    return 80.5m;
}

public class Customer
{
    public string Email { get; set; } = "";
    public decimal LastOrderTotal { get; set; }
    public int Points { get; set; }
}

Null-Conditional Assignment

The output is two lines:

calculating for ada
80.5

CalculateTotal ran only once. Because nobody is null, the right-hand side of the assignment was never evaluated. The rule is simple: if the left-hand side is null, the whole expression is skipped, right-hand side included. In C# 10 you wrote the same thing with an if:

Customer? ada = new Customer();
if (ada != null)
    ada.Points += 10;
Console.WriteLine(ada?.Points);

In C# 14 compound assignment works too:

Customer? ada = new Customer();
Customer? nobody = null;

ada?.Points += 10;
nobody?.Points += 10;
Console.WriteLine($"{ada?.Points} {nobody?.Points}");

public class Customer
{
    public int Points { get; set; }
}
10 

The first value is 10 and the second is empty, because nobody?.Points is null. ++ and --, however, were left out on purpose: the line c?.Points++; gave error CS1059: The operand of an increment or decrement operator must be a variable, property or indexer. Compiling with C# 13: error CS9260: Feature 'null conditional assignment' is not available in C# 13.0. Please use language version 14.0 or greater. In this post I used C# 13 for comparison. Every version before C# 14 rejects these features; only the error code changes: with C# 10 the same line gives error CS8936: Feature 'null conditional assignment' is not available in C# 10.0. Please use language version 14.0 or greater.

Extension Members

Extension methods have been around since C# 3, but they always stayed "methods". In C# 10 an extension that tells you whether a book is expensive looked like this:

public static class BookExtensions
{
    public static bool IsExpensive(this Book book) => book.Price > 40m;
}

And the call had parentheses: book.IsExpensive(). An extension property, adding a static member to a type, or writing an extension operator wasn't possible. C# 14 opened these up with the extension block:

public record Book(string Title, decimal Price);

public static class BookExtensions
{
    extension(Book book)
    {
        public bool IsExpensive => book.Price > 40m;
    }

    extension(Book)
    {
        public static Book Placeholder => new("Untitled", 0m);
    }

    extension(IEnumerable<Book> books)
    {
        public decimal TotalPrice => books.Sum(b => b.Price);
    }
}

books[0].IsExpensive printed True, books.TotalPrice printed 80.5, and Book.Placeholder.Title printed Untitled. The extension(Book book) block declares a receiver; every member inside can use that receiver. extension(Book) is an unnamed receiver; the static members inside are called as if they had been added to Book itself. The receiver can also be an interface; TotalPrice works on every IEnumerable<Book>. Operators come the same way:

using System.Globalization;

var a = new Money(10m);
var b = new Money(5.5m);
Console.WriteLine((a + b).Amount.ToString(CultureInfo.InvariantCulture));

public record Money(decimal Amount);

public static class MoneyExtensions
{
    extension(Money)
    {
        public static Money operator +(Money left, Money right) => new(left.Amount + right.Amount);
    }
}
15.5

Classic extension methods with a this parameter keep working as they are; you don't have to change your existing code. When you can't touch a type, for example when you want to add a computed property to a DTO that comes from a NuGet package, this syntax is much more natural.

The field Keyword

When you wanted to add a little logic to a property's setter in C# 10, you had to give up the auto-property and write the backing field by hand:

public class Author
{
    private string _name = "";

    public string Name
    {
        get => _name;
        set => _name = value.Trim();
    }
}

In C# 14, field stands for the backing field the compiler generates:

public class Author
{
    public required string Name
    {
        get;
        set => field = value.Trim();
    }
}

For new Author { Name = " Robert C. Martin " } both versions printed [Robert C. Martin]. get stayed an auto-accessor; we added logic only to set. You no longer have to name the field or worry about writing to it directly by mistake from somewhere else in the class; field is reachable only from the property's accessors. The C# 13 error: error CS9260: Feature 'field keyword' is not available in C# 13.0. Please use language version 14.0 or greater. In C# 13 this feature existed only as a preview. One warning: if your class already has a member named field, its meaning changes with C# 14; we'll see that with a real warning in the next post, which is about upgrading versions.

Span Conversions

C# 14 made the conversions between arrays and Span<T>/ReadOnlySpan<T> part of the language. The most visible result is in extension methods:

public static class SpanExtensions
{
    public static decimal Total(this ReadOnlySpan<decimal> values)
    {
        decimal total = 0;
        foreach (decimal value in values)
            total += value;
        return total;
    }
}

For decimal[] prices = [42.5m, 38m, 55m];, prices.Total() printed 135.5 in C# 14. The same code doesn't compile in C# 13:

Program.cs(2,19): error CS1929: 'decimal[]' does not contain a definition for 'Total' and the best extension method overload 'SpanExtensions.Total(ReadOnlySpan<decimal>)' requires a receiver of type 'System.ReadOnlySpan<decimal>'

The conversion from array to span existed before, but as a user-defined conversion; it wasn't considered for an extension method receiver. Now it is, and generic type inference understands spans better too. This improvement also brought a breaking change: a call like array.Reverse() can now bind to span's in-place Reverse instead of LINQ. I'll show its real error in the next post as well.

nameof and Lambda Parameter Modifiers

Two small but nice additions. nameof now accepts generic types with no type arguments:

Console.WriteLine(nameof(List<>));
Console.WriteLine(nameof(Dictionary<,>));
List
Dictionary

In C# 10 you had to make up an arbitrary type argument, like nameof(List<int>). The C# 13 error: Feature 'unbound generic types in nameof operator' is not available in C# 13.0.

The second one is in lambdas. For a delegate with an out parameter, in C# 10 you had to write the types of the lambda parameters one by one. In C# 14 writing the modifier is enough, and the type is inferred from the delegate:

TryParser<decimal> parse = (text, out result) => decimal.TryParse(text, CultureInfo.InvariantCulture, out result);
Console.WriteLine(parse("42.5", out decimal parsed) ? parsed : -1);

public delegate bool TryParser<T>(string text, out T result);
42.5

Modifiers like out no longer need a type; params, however, still does. The C# 13 error: Feature 'simple lambda parameter modifiers' is not available in C# 13.0.

Partial Constructors, Partial Events and Compound Assignment Operators

After C# 13's partial properties, it's the turn of constructors and events:

var book = new Book("  Clean Code ");
Console.WriteLine($"[{book.Title}]");

public partial class Book
{
    public partial Book(string title);

    public string Title { get; private set; } = "";
}

public partial class Book
{
    public partial Book(string title)
    {
        Title = title.Trim();
    }
}
[Clean Code]

Events follow the same pattern: public partial event EventHandler? StockChanged; in one part, and the implementation with add and remove accessors in the other. I tried that too, and the subscribed lambda printed stock changed. As with partial properties, the typical use is code where one part is produced by a source generator.

The last big addition is being able to write your own compound assignment operator. In C# 10, when you wrote cart += 42.5m the compiler turned it into cart = cart + 42.5m; in other words the + operator had to produce a new object every time. In C# 14 you can define += as an operator that works in place on the instance:

var cart = new CartTotal();
cart += 42.5m;
cart += 38m;
Console.WriteLine(cart.Value);

public class CartTotal
{
    public decimal Value { get; private set; }

    public void operator +=(decimal amount) => Value += amount;
}
80.5

The operator returns void and isn't static; it changes the target object. That makes a difference for types that are expensive to copy, like large buffers or matrices. The C# 13 error: Feature 'user-defined compound assignment operators' is not available in C# 13.0. To complete the list: the new directives such as #:package and the #! shebang line for .NET 10's file-based apps, run with dotnet run app.cs, are also part of C# 14.

Common Pitfalls

Assuming the right-hand side of a ?. assignment always runs. As you saw in the opening question, if the left-hand side is null the right-hand side never runs. If you rely on a side effect there (a log, a counter, a database call), this behavior will surprise you.

Using ?. on a variable you know isn't null. When I wrote var ada = new Customer(); ada?.Points += 10; and read ada.Points on the next line, the compiler gave warning CS8602: Dereference of a possibly null reference. The moment you use ?., flow analysis concludes "so it can be null".

Expecting a meaningful message on an old LangVersion. Unlike the other features, an extension block doesn't produce a "feature not available" error in C# 13; since the compiler doesn't recognize the syntax at all, it says this:

Program.cs(6,6): error CS1513: } expected
Program.cs(9,1): error CS1022: Type or namespace definition, or end-of-file expected

If you see these errors in a file that contains extension, check the language version first.

Spreading extension members everywhere. Don't write an extension to add a property to your own type; change the type. Extensions are for types you don't own.

A Small Experiment

★☆☆ Add an extension property called Cheapest for IEnumerable<Book> to BookExtensions and call it with books.Cheapest.Title.

★★☆ Add a check to the field example in Author that throws ArgumentException when value is empty. How does it behave together with required?

★★★ Define both a static operator + and an instance operator += on CartTotal. Which one does cart += 1m call, and which one does var other = cart + 1m? Does the reference held in cart change in either case?

With that we've reached C# 14, the language of today's LTS release .NET 10. In the last post of the series we apply what we've learned to a project: we move a project stuck on .NET 6 to .NET 10 and read the real warnings and breaking changes that come up along the way, one by one.