← Back to Article List
What’s New in C# 14: Exploring the Latest Features in .NET 10

What’s New in C# 14: Exploring the Latest Features in .NET 10

Published on 01 Jan 0001     12 min read C#
C Sharp

New in C# 14: Exploring the Latest Features in .NET 10

C# continues to evolve with every .NET release. With the release of C# 14 along with .NET 10, several new language features have been introduced to improve developer productivity and make code cleaner.

These updates mainly focus on reducing boilerplate code, improving extension capabilities, and making everyday development tasks easier. The goal is not to drastically change the language, but to simplify common programming patterns.

In the following sections, we will look at the new features introduced in C# 14 and briefly understand what each feature provides for developers.

Here is a simple list of the new features in C# 14 (released with .NET 10):

  1. Extension Members (Extension Blocks)

  2. Extension Properties

  3. Static Extension Members

  4. Private Fields inside Extension Blocks

  5. Field-backed Properties (field keyword)

  6. Null-conditional Assignment (?.= and ?[]= support)

  7. nameof Support for Unbound Generic Types

  8. Implicit Conversions for Span<T> and ReadOnlySpan<T>

  9. Parameter Modifiers in Lambda Expressions (ref, in, out, etc.)

  10. User-defined Compound Assignment Operators

  11. Partial Constructors and Partial Events

  12. New Preprocessor Directives for File-based Apps

Extension Members

Before C# 14, developers used extension methods to add functionality to existing types without modifying the original class. When many extensions were needed for the same type, the code could become repetitive and harder to organize.

C# 14 introduces Extension Blocks, which allow multiple extension members to be grouped together for a specific type. This reduces repetition and makes extension code easier to maintain.

Example – Before C# 14 (Traditional Extension Methods)

// Before
public static class StringExtensions
{
    public static bool IsLong(this string value)
    {
        return value.Length > 10;
    }

    public static string FirstCharacter(this string value)
    {
        return value.Substring(0, 1);
    }
}

Usage: 

string text = "HelloWorldExample";

bool result = text.IsLong();
string firstChar = text.FirstCharacter();

Here, every extension method must explicitly declare this string value.

Example – Using Extension Blocks in C# 14

public static class StringExtensions
{
    extension(string value)
    {
        public bool IsLong()
        {
            return value.Length > 10;
        }

        public string FirstCharacter()
        {
            return value.Substring(0, 1);
        }
    }
}

Why This Feature is Useful

Extension blocks allow developers to group related extensions together, making the code cleaner and easier to maintain when working with many helper methods.

Extension Properties

Before C# 14, extension methods allowed developers to add new methods to existing types without modifying the original class. However, it was not possible to add properties using the extension mechanism.

C# 14 introduces Extension Properties, which allow developers to define property-like members for existing types. This makes APIs more natural to use because developers can access values like a property instead of calling a method.

Example – Before C# 14

Earlier, if we wanted property-like behavior, we had to implement it as a method.

public static class StringExtensions
{
    public static int WordCount(this string text)
    {
        return text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
    }
}

Even though this represents a value, it still has to be called like a method.

Example – Using Extension Properties in C# 14

With C# 14, the same logic can be written as an extension property.

public static class StringExtensions
{
    extension(string text)
    {
        public int WordCount
        {
            get => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
        }
    }
}

Why This Feature is Useful

Extension properties allow developers to expose computed values in a property-style syntax, making APIs cleaner and easier to read. This is especially useful when extending framework types or domain objects where the functionality logically represents a property rather than a method.

Static Extension Members

Before C# 14, extension methods could only extend instance members of a type. It was not possible to extend static members of a class using the extension mechanism.

C# 14 introduces Static Extension Members, which allow developers to add static methods or properties to an existing type without modifying the original class. This is useful when you want to extend utility functionality related to a type.

Example – Normal Static Method (Before C# 14)

// Before
public class MathHelper
{
    public static int Square(int number)
    {
        return number * number;
    }
}

//Usage

int result = MathHelper.Square(5);

Here the static method must be defined inside the class itself.

Example – Static Extension Member in C# 14

// After
public static class IntExtensions
{
    extension(int)
    {
        public static int Square(int number)
        {
            return number * number;
        }
    }
}

// Usage

int result = int.Square(5);

Why This Feature is Useful

Static extension members allow developers to extend static behavior of existing types without modifying the original source code. This helps keep utility logic organized and makes APIs more flexible when working with framework or third-party types.

Private Fields inside Extension Blocks

C# 14 allows private fields to be declared inside extension blocks. These fields can be used internally by the extension members defined within the same block. This helps developers reuse internal values or logic without exposing them outside the extension.

This feature improves organization because helper data used by extension methods can stay inside the extension block, instead of being declared separately in the static class.

Example

public static class StringExtensions
{
    extension(string text)
    {
        private const int MaxLength = 10;

        public bool IsTooLong()
        {
            return text.Length > MaxLength;
        }

        public string Shorten()
        {
            if (text.Length <= MaxLength)
                return text;

            return text.Substring(0, MaxLength) + "...";
        }
    }
}

// Usage
string message = "Welcome to C# 14 extension block example";

bool result = message.IsTooLong();
string shortText = message.Shorten();

Why This Feature is Useful

Private fields inside extension blocks allow developers to share internal values or logic between extension members. This keeps the code cleaner and avoids repeating the same values or helper logic in multiple methods.

Field-Backed Properties (field keyword)

In C#, auto-implemented properties automatically create a hidden backing field to store the value. Before C# 14, if developers wanted to add validation or logic inside a property setter, they usually had to create a separate private field manually.

C# 14 introduces the field keyword, which allows direct access to the compiler-generated backing field inside the property. This makes it easier to add validation or logic without declaring an extra variable.

Example – Before C# 14

// Before
public class Product
{
    private decimal _price;

    public decimal Price
    {
        get { return _price; }
        set
        {
            if (value < 0)
                throw new ArgumentException("Price cannot be negative");

            _price = value;
        }
    }
}

Here we must explicitly create the _price backing field.

Example – Using field keyword in C# 14

// After
public class Product
{
    public decimal Price
    {
        get;
        set
        {
            if (value < 0)
                throw new ArgumentException("Price cannot be negative");

            field = value;
        }
    }
}

// Usage
Product product = new Product();
product.Price = 100;

Why This Feature is Useful

The field keyword allows developers to add logic to auto-properties without declaring a separate backing field. This reduces boilerplate code and keeps property implementations simpler and cleaner.

Null-Conditional Assignment (?.= and ?[]=)

In C#, developers often check whether an object is null before assigning a value to one of its properties or indexers. Without this check, the code may throw a NullReferenceException.

C# 14 introduces null-conditional assignment, which allows assignments to happen only if the object is not null. This reduces the need for explicit null checks and keeps the code shorter and easier to read.

Example – Before C# 14

// Before
if (person != null)
{
    person.Name = "John";
}

Here we must explicitly check if person is not null before assigning the value.

Example – Using ?.= in C# 14

// After
person?.Name = "John";

In this case, the assignment happens only if person is not null.
If person is null, the statement simply does nothing.


Example – Using ?[]= with Indexers

Consider assigning a value to a list item.

Before C# 14

// Before
if (numbers != null)
{
    numbers[0] = 10;
}

Using ?[]= in C# 14

// After
numbers?[0] = 10;

The value is assigned only if the list is not null.

Why This Feature is Useful

Null-conditional assignment reduces repetitive null checks in the code.
It makes the code shorter, safer, and easier to read, especially when working with objects that may be null.

nameof Support for Unbound Generic Types

The nameof operator returns the name of a type, variable, or member as a string. It is commonly used in logging, validation, and exception messages to avoid hard-coded strings.

Before C# 14, when using nameof with generic types, developers had to specify the generic type argument. In C# 14, this restriction is removed, and nameof can work with unbound generic types (generic types without specifying the type parameter).

Example – Before C# 14

// Before
Console.WriteLine(nameof(Dictionary<string, int>));

Output

List

Here we must provide a specific type parameter (int).

Example – In C# 14

// After
string name = nameof(List<>);
Console.WriteLine(name);

Output 

List

Now the generic type can be referenced without specifying the type argument.

Why This Feature is Useful

This feature makes code simpler and cleaner when working with generic types. Developers no longer need to provide a specific type just to retrieve the generic type name.

Implicit Conversions for Span<T> and ReadOnlySpan<T>

Span<T> and ReadOnlySpan<T> are commonly used in C# for high-performance memory operations. They allow developers to work with arrays, strings, or memory slices without creating additional allocations.

Before C# 14, developers sometimes needed explicit conversions when working with spans. C# 14 improves this by allowing more implicit conversions, making it easier to work with Span<T> and ReadOnlySpan<T> without extra casting.

Example – Before C# 14

// Before
string text = "Hello World";

ReadOnlySpan<char> span = text.AsSpan();

Here we explicitly call AsSpan() to convert the string into a span.

Example – In C# 14

string text = "Hello World";

ReadOnlySpan<char> span = text;

Now the conversion happens automatically, making the code simpler.

Another Example

char[] letters = { 'A', 'B', 'C', 'D' };

Span<char> span = letters;

An array can be automatically converted to a Span<char>.

Why This Feature is Useful

Implicit conversions reduce unnecessary method calls and make code simpler and more readable. It also encourages developers to use Span<T> and ReadOnlySpan<T> more easily in performance-critical scenarios.

Parameter Modifiers with Type Inference in Lambdas

In C# 14, lambda expressions can now infer parameter types even when using modifiers like out, ref, or in. This makes lambdas shorter and easier to read.

Example

First we define a delegate:

delegate bool TryParse<T>(string text, out T result);

// Lambda Usage

TryParse<int> parse = (text, out result) => int.TryParse(text, out result);

Here:

  • text is automatically inferred as string

  • result is inferred as int

  • The out modifier still works correctly

So the compiler infers the types from the delegate definition, which removes the need to write the full parameter types in the lambda.

Why This Improvement Matters

This feature makes lambda expressions cleaner and less verbose, especially when working with delegates that use ref, in, or out parameters. Developers can write shorter code while still keeping the correct parameter behavior.

User-defined Compound Assignment Operators

Compound assignment operators such as +=, -=, *=, and /= are commonly used in C# to perform an operation and assign the result in a single step.

In earlier versions of C#, when developers created custom types, these compound operators usually worked through the normal operator overloads like + or -. C# 14 improves this by allowing developers to define compound assignment operators directly for their own types.

Example

Suppose we have a simple Counter class.

public class Counter
{
    public int Value { get; set; }

    public static Counter operator +=(Counter counter, int amount)
    {
        counter.Value += amount;
        return counter;
    }
}

// Usage

Counter counter = new Counter { Value = 10 };

counter += 5;

Console.WriteLine(counter.Value); // Output: 15

Why This Feature is Useful

User-defined compound assignment operators allow custom types to behave more naturally with operators. This makes the code easier to read and helps developers design classes that work smoothly with common C# operator patterns.

Partial Constructors and Partial Events

C# supports partial types, which allow a class to be split across multiple files. This is commonly used in scenarios such as source generators, large projects, or auto-generated code.

With C# 14, this concept is extended to support partial constructors and partial events. This means a constructor or event can be declared in one part of the class and implemented in another part.

Example – Partial Constructor

// File 1
public partial class User
{
    partial void Initialize();
}

// File 2
public partial class User
{
    partial void Initialize()
    {
        Console.WriteLine("User initialized");
    }

    public User()
    {
        Initialize();
    }
}

Here the initialization logic is separated, but it still belongs to the same class.

Partial Events

In large applications, a class is sometimes split across multiple files using partial classes. Before C# 14, events had to be fully implemented in a single place. C# 14 introduces partial events, allowing the event declaration and its implementation to be placed in different parts of the class.

This is especially useful when working with generated code and developer-written code, where one file declares the event and another file defines how the event behaves.

Example – Order Processing System

Suppose we have an order service that raises an event when an order is created.

File 1 – Event Declaration

public partial class OrderService
{
    public partial event Action OrderCreated;

    public void CreateOrder()
    {
        Console.WriteLine("Order created successfully");

        OrderCreated?.Invoke();
    }
}

Here we declare the event, but the add/remove logic is not defined yet.

File 2 – Event Implementation

public partial class OrderService
{
    private Action _orderCreatedHandlers;

    public partial event Action OrderCreated
    {
        add
        {
            Console.WriteLine("Subscriber added");
            _orderCreatedHandlers += value;
        }
        remove
        {
            Console.WriteLine("Subscriber removed");
            _orderCreatedHandlers -= value;
        }
    }
}

Here we define how subscribers are added and removed.

Usage

OrderService service = new OrderService();

service.OrderCreated += () =>
{
    Console.WriteLine("Notification: Order was created");
};

service.CreateOrder();

Output

Subscriber added
Order created successfully
Notification: Order was created

Why This Feature is Useful

Partial events allow developers to separate event declaration and implementation across different files. This keeps code cleaner and works well in scenarios where part of the code is generated automatically and the rest is written manually.