Missed these 12 new C# features?

Published: Monday 26 January 2026

We are going to look at 12 new features from the last three editions of C# that you might have missed.

C# 12

Primary constructors

Previously, if you wanted to pass parameters into a class, you had to create a constructor, pass in the parameters and then store the values in a field.

public class Product {
	private readonly int _id;

	public Product(int id) {
		_id = id;
	}
}

With primary constructors, you can add parameters directly to the class declaration and use them throughout the class. As a result, you no longer need an explicit constructor.

public class Product(int id) {
	public int GetIdNumber() {
		return id;
	}
}

To initialise it, you call:

var p = new Product(9);
Console.WriteLine(p.GetIdNumber());

Collection expressions

Collection expressions bring the spread operator (..) to C#. They allow you to combine collection types such as arrays, spans and lists into a single collection. You can also prepend and append values.

var arrayInt = new int[] { 1, 2, 3 };
var spanInt = new Span<int>([2, 4, 5, 4, 4]);
var listInt = new List<int> { 4, 6, 6, 5 };

int[] fullListArray = [1, ..arrayInt, ..spanInt, ..listInt, 9];

foreach (var a in fullListArray) {
	Console.WriteLine(a);
}

When you run this code in a console application, it outputs:

1
1
2
3
2
4
5
4
4
4
6
6
5
9

Default lambda parameters

With lambda expressions or statements, you can set default values for parameters, making them optional.

In this example, if you invoke addNumbers with a set to 4 and omit b, it outputs 5.

var addNumbers = (int a, int b = 1) => a + b;
Console.WriteLine(addNumbers.Invoke(4));

Inline arrays

On a struct, you can apply the InlineArray attribute and specify the length of the array. Inside the struct, you need a field to store the value for each array index.

[InlineArray(10)]
public struct Buffer {
	private int _a;
}

To use it, create a new instance of the struct, then iterate through each index and set its value.

var buffer = new Buffer();

for (var a = 0; a <= 9; a++) {
	buffer[a] = a + 9;
	Console.WriteLine(buffer[a]);
}

The console application outputs:

9
10
11
12
13
14
15
16
17
18

C# 13

Now looking at new features in C# 13.

params collections

Before C# 13, when using the params keyword, the type had to be an array.

public class Product {
	public List<int> RelatedIds(params int[] a) {
		return a.ToList();
	}
}

In C# 13, you can use a wider range of collection types, including List.

public class Product {
	public List<int> RelatedIds(params List<int> a) {
		return a;
	}
}

This is how you would use it:

var product = new Product();
product.RelatedIds(9, 3, 4);

allows ref struct

If you use ref structs, you can now use them in generic types and methods.

public class AllowsRefStruct<T> where T : allows ref struct {
}

public ref struct Product {
	public int Id { get; set; }
}

When you initialise AllowsRefStruct and use Product as the generic type, it now compiles:

var a = new AllowsRefStruct<Product>();

ref struct interfaces

You can now implement an interface on a ref struct.

public ref struct Product : IProduct {
	public int Id { get; set; }
}

public interface IProduct {
}

Partial properties and indexers

Partial classes are commonly used by source generators to add declarations while allowing you to provide the implementation. With C# 13, you can now declare partial properties and indexers.

If you mark a property or indexer as partial, you must create another partial class with the same name and namespace to implement it.

// Declaration
public partial class Product {
	public partial int Id { get; }

	public partial int this[int x] { get; }
}

// Implementation
public partial class Product {
	public partial int Id { get => 3; }

	public partial int this[int x] { get => Id; }
}

C# 14

Now looking at new features in C# 14.

Partial constructors and events

Continuing with partial classes, partial constructors and events were added in C# 14. As with properties and indexers, one partial class contains the declaration and the other contains the implementation.

// Declaration
public partial class PartialConstructorsAndEvents
{
	private EventHandler _eventHandlers;

	public string Name { get; }

	// Partial constructor signature
	public partial PartialConstructorsAndEvents(string name);

	// Partial event signature
	public partial event EventHandler? OnNameChange;
}

// Implementation
public partial class PartialConstructorsAndEvents
{
	// Partial constructor implementation
	public partial PartialConstructorsAndEvents(string name)
	{
		Name = name;
	}

	// Partial event implementation
	public partial event EventHandler? OnNameChange
	{
		add => _eventHandlers += value;
		remove => _eventHandlers -= value;
	}
}

Extension members

Before extension members, you had extension methods. These required a static class and the this keyword to extend a type.

public class Product {
	public decimal Price { get; set; }
}

public static class ProductExtensions {
	public static decimal GetPrice(this Product product) {
		return product.Price;
	}
}

With extension members, you can use an extension block. The static keyword is no longer required, and the extended type is included in the block.

public class Product {
	public decimal Price { get; set; }
}

public static class ProductExtensions {
	extension(Product product) {
		public decimal GetPrice() {
			return product.Price;
		}
	}
}

To use this, initialise the Product class and call GetPrice:

var product = new Product();
var price = product.GetPrice();

field keyword

If logic is required when setting a property, the value previously had to be stored in a private field and returned from the getter.

public class Product {
	private decimal _discount;

	public decimal Discount {
		get => _discount;
		set => _discount = value >= 0
			? value
			: throw new ArgumentOutOfRangeException("Discount must be 0 or greater");
	}
}

With the field keyword, the private backing field can be removed. The getter no longer references it, and the setter uses field instead.

public class Product {
	public decimal Discount {
		get;
		set => field = value >= 0
			? value
			: throw new ArgumentOutOfRangeException("Discount must be 0 or greater");
	}
}

Null-conditional assignment

Previously, you needed an if statement to perform a null check.

Product? product = null;

if (product != null) {
	product.Discount = 3;
}

With C# 14, you can apply the null-conditional operator to the left-hand side. If the value is null, the assignment is ignored.

Product? product = null;
product?.Discount = 3;

Watch the video

Watch the video to see these C# features in action. It also shows how to use a newer C# version in an older .NET project.

Minimal API for complete beginners course

We use C# 14 and .NET 10 in our Minimal APIs for complete beginners course. The course also covers unit testing, essential features, and setting up OpenAPI/Swagger documentation.