What's New in C# 14: An Overview of New Features

C#

Dive into the latest features of C# 14, including the 'field' keyword, enhanced lambda parameters, operator overloading, null-conditional assignment, and more for cleaner, more efficient code.

October 21, 2025

As C# 14 approaches, we present our annual overview of its new features. While this iteration introduces fewer changes than its predecessor, these additions, though seemingly minor to some, bring significant enhancements to the language. Let's delve into the details.

The field Keyword

C# 14 introduces the ability to omit explicitly defining a backing field for a property. While auto-implemented properties like public string Name { get; set; } have long provided a hidden backing field, their utility is limited to scenarios without additional logic. Historically, implementing custom logic within a property setter necessitated the explicit declaration of a private backing field to store and process the data.

Consider the common scenario of trimming whitespace from an email address before storage. Previously, this would typically involve:

public class User
{
  private string _oldEmail;
  public string OldEmail 
  {
    get => _oldEmail;
    set => _oldEmail = value.Trim();
  }
}

With C# 14, this process is streamlined. Developers can now utilize the new field keyword within the property setter, eliminating the need for an explicit field declaration:

public class User
{
  public string Email 
  {
    get; // The `field` is used here implicitly
    set => field = value.Trim();
  }
}

This long-anticipated feature significantly enhances code readability and further reduces boilerplate, allowing developers to focus more on property logic rather than manual field management.

Lambda Parameters with Modifiers Without Explicit Typing

Do you recall how using modifiers like scoped, ref, in, out, or ref readonly within a lambda expression previously required explicitly specifying the parameter type? Here's an example:

delegate bool ValidatorHandler(object value, out string errorMessage);
public void Validate(object objectValue)
{
  ValidatorHandler Validator = (object value, out string error) =>
  {
    // ....
  };
  // ....
}

C# 14 now permits the use of these modifiers without needing to explicitly state the parameter type:

ValidatorHandler Validator = (value, out error) =>
{
  // ....
};

This innovation allows developers to concentrate more on the functional aspects of their code, reducing the need for verbose type declarations. This feature will likely find its most frequent use with out parameters.

Overloading of Compound Assignment Operators

This optimization feature extends operator overloading beyond just unary or binary operators to include compound assignment operators such as +=, *=, and others. Given their similar implementation, we'll use += as an example, though the conclusions apply to analogous operators.

In C# 13, when using the += operator, the overloaded + operator was invoked first, followed by the assignment.

When working with value types, overloading the + operator typically creates additional copies of both operands and a new instance for the result. While expected, this behavior can lead to unwarranted performance overhead from copying and processing values, particularly with large types like mathematical vectors or tensors.

To circumvent this, one might write a method similar to += that minimizes copying and allows modification of the first operand:

public struct Vector3
{
  public double X, Y, Z;

  public Vector3(double x, double y, double z)
  {
    X = x;
    Y = y;
    Z = z;
  }

  public void Add(Vector3 vector)
  {
    X += vector.X;
    Y += vector.Y;
    Z += vector.Z;
  }
}

Although the Add method effectively avoids most drawbacks, it appears less idiomatic than the += operator. With C# 14, overloading the += operator can be done directly:

public void operator +=(Vector3 right)
{
  X += right.X;
  Y += right.Y;
  Z += right.Z;
}

This approach maintains the desired behavior while significantly enhancing code readability and preventing unexpected performance dips that can occur when developers implicitly create new objects with compound assignments on large value types.

A quick note on reference types: if the += operator is not overloaded for a reference type, its use will similarly invoke the + operator followed by assignment. However, if the + operator is implemented to modify the existing object rather than creating a new one (as in the Vector3 example), using += without its own overload might still lead to unintended object creation within the + implementation.

More Partial Members

C# 13 introduced partial support for properties and indexers. C# 14 expands this capability to allow partial constructors and events.

Consistent with other partial member uses, this feature will prove particularly beneficial for library developers and code generators. For instance, partial events could simplify weak event libraries, and partial constructors would aid platforms generating interop code, such as Xamarin.

It's important to note that, unlike partial methods which can remain declared without an implementation, partial constructors and partial events must have an implementation spread across their parts.

Null-Conditional Assignment

Continuing its focus on improving readability, C# 14 introduces a feature that enables checking a variable for null not just on the right side of an expression, but also on the left side during assignment.

Important! Increment and decrement operators cannot be used in conjunction with null-conditional assignment.

Previously, assignments that involved potential null dereferencing required a classic if statement to check for null:

public class User
{
  public DateTime LastActive { get; set; }
  public bool LoggedIn { get; set; }

  // ....

  public void UpdateLastActivity(User user)
  {
    if (user is not null)
      user.LastActive = DateTime.UtcNow;
    // ....
  }
}

In C# 14, this can be concisely expressed in a single line:

public void UpdateLastActivity(User user)
{
  user?.LastActive = DateTime.UtcNow;
  // ....
}

Before C# 14, the null-conditional operator (?.) could only be used for checks, not for performing assignments directly:

if (user?.LoggedIn == true) // OK
{
  user?.LoggedIn = false; // Error in previous versions
}

The behavior remains consistent: the right side of the expression will not be evaluated if the null check fails. However, the code becomes significantly more compact and readable.

Extension Elements

C# 14 expands the concept of extension members beyond just methods to include extension properties. A new keyword, extension, has been introduced to define these extension blocks.

How was this simulated previously? The old approach was often cumbersome, requiring workarounds and more verbose code. Let's illustrate with an implementation example:

public static class ExtensionMembers
{
  public static EnumerableWrapper<TSource> AsExtended<TSource>
  (this IEnumerable<TSource> source) =>
    new(source);
}

public class EnumerableWrapper<T> : IEnumerable<T>
{
  private readonly IEnumerable<T> _source;

  public EnumerableWrapper(IEnumerable<T> source)
  {
    _source = source;
  }

  public IEnumerator<T> GetEnumerator() => _source.GetEnumerator();
  IEnumerator IEnumerable.GetEnumerator() => _source.GetEnumerator();

  public bool IsEmpty => !_source.Any();
}

public class TestClass
{
  public void OldExtensions()
  {
    var enumerable = new List<int>();

    bool isEmpty = enumerable.AsExtended().IsEmpty;
  }
}

To access the IsEmpty property, IEnumerable had to be wrapped, which is rather clunky. Now, observe the streamlined approach with extension elements:

public static class ExtensionMembers
{
  extension<TSource>(IEnumerable<TSource> source)
  {
    public bool IsEmpty => !source.Any();
  }
}

public class TestClass
{
  public void NewExtensions()
  {
    var enumerable = new List<int>();

    bool isEmpty = enumerable.IsEmpty;
  }
}

The resulting code is simpler and more pleasant to read, and such extensions feel much more natural. Note that IsEmpty is called as an instance member of IEnumerable<TSource>. We can also create static members by declaring the extension block as follows:

extension<TSource>(IEnumerable<TSource>)
{
  // ....
}

Extension elements offer even more interesting possibilities, such as simplifying age-old string checks or creating a getter to capitalize the first letter of a string:

public static class StringExtensions
{
  extension(string str)
  {
    public bool IsNullOrEmpty => string.IsNullOrEmpty(str);
    public bool IsNullOrWhiteSpace => string.IsNullOrWhiteSpace(str);

    public string ToTitleCase => 
      System.Globalization.CultureInfo
      .CurrentCulture.TextInfo
      .ToTitleCase(str.ToLower());  
  }
}

Ultimately, developers can now extend types more naturally with properties, not just methods, without resorting to wrapper classes.

Implicit Span Conversions

This change facilitates the creation of more optimized code with less effort. C# has enhanced its ability to perform implicit conversions, making Span<T> more convenient to use. First, let's understand why Span<T> is crucial for improving code performance.

Note: Using Span<T> is critical in high-performance scenarios where every millisecond matters.

Span<T> is a type designed for efficient memory management. It acts like a "window" into a contiguous block of memory. For example, if you need to perform an operation on a portion of a string, the Substring method typically creates a new string, incurring unnecessary memory overhead. Span<T> helps avoid this.

To get a substring without Span<T>, you would write:

string str = "test string";
string testString = str.Substring(startIndex: 0, length: 4);

This allocates memory for a new substring, which is inefficient. Here is the version with Span<T>:

ReadOnlySpan<char> testSpan = str.AsSpan().Slice(start: 0, length: 4);

In the Span<T> example, no new memory is allocated for the "substring" because we directly access the memory containing the original string, which is faster and more memory-efficient.

Think of Span<T> like finding a specific chapter in a book; you wouldn't rewrite the chapter, you'd just find its beginning and end. Additionally, Span<T>, similar to arrays, prevents accessing memory outside its allocated "window," a risk associated with unsafe memory access. It performs bounds checks and throws an error if an index is out of range.

With a general understanding of Span<T>, let's examine the specific change. Implicit conversions to Span<T> were already available, but the list has now been expanded.

Suppose we have a method to process ReadOnlySpan<T>:

public static void ProcessSpan<T>(ReadOnlySpan<T> span)
{
  // ....
}

And we try to call it with different data types (before C# 14):

var intArray = new int[10];
string str = "test";
Span<char> charSpan = new();
ReadOnlySpan<char> charReadOnlySpan = new();

ProcessSpan(str);              // Error
ProcessSpan(intArray);         // Error
ProcessSpan(charSpan);         // Error
ProcessSpan(charReadOnlySpan); // OK

Previously, no implicit conversions occurred for the first three calls. To make them work, we had to explicitly convert them:

ProcessSpan(str.AsSpan());
ProcessSpan<int>(intArray.AsSpan());
ProcessSpan<char>(charSpan);
ProcessSpan(charReadOnlySpan);

With the new implicit conversions in C# 14, things become much simpler. The only remaining caveat is that for strings, you still need to specify the generic char parameter:

ProcessSpan<char>(str);
ProcessSpan(intArray);
ProcessSpan(charSpan);
ProcessSpan(charReadOnlySpan);

Now, writing more performant code with Span<T> and spending less time on refactoring is significantly easier.

Unbound Generic Types and nameof

Previously, obtaining a type name using nameof required specifying a parameter, typically a closed generic type:

Console.WriteLine(nameof(List<int>)); // Output: List

C# 14 now supports nameof for unbound generic types:

Console.WriteLine(nameof(List<>)); // Output: List

While the output for both statements is the same, using nameof(List<>) clearly expresses the intent to retrieve the name of the open generic type. This clarity simplifies refactoring and code comprehension, eliminating the need to infer why a specific type parameter (like int) was used in the nameof expression.

Conclusion

C# 14 introduces several new features that enhance convenience and expand developer capabilities. As is common with new language versions, some of these are "syntactic sugar" that significantly improve the developer experience. Looking ahead, we anticipate that the new extension elements, the field keyword, and null-conditional assignment will be among the most highly demanded features. What are your thoughts on these changes? Please share them in the comments.

You are welcome to review the C# 14 documentation via this link. Don't forget to check out our overviews of previous versions: