Put your product in front of .NET and C# developers.

C# 15: Union types and other new features

David Grace David Grace

C# 15 is here, and it fixes a problem I've been working around for years.

Here is the problem. I have a Product record, and two records that derive from it.

public abstract record Product(
	int Id,
	string Name,
	decimal Price);
public record BookProduct(
	int Id,
	string Name,
	decimal Price,
	string Author)
	: Product(Id, Name, Price);
public record ElectricalProduct(
	int Id,
	string Name,
	decimal Price,
	int WarrantyMonths)
	: Product(Id, Name, Price);

I've also got a CalculateTax method that returns a different tax amount depending on the type of product.

public static class ProductExtensions
{
	public static decimal CalculateTax(Product product)
		=> product switch
		{
			BookProduct => 0m,
			ElectricalProduct electrical => electrical.Price * 0.2m,
			_ => throw new ArgumentOutOfRangeException(nameof(product))
		};
}

I have to add a default case that throws an exception. That's because BookProduct and ElectricalProduct both derive from Product, but the compiler doesn't know they're the only two. Any type could derive from Product.

The problem comes later. If I add a new type that derives from Product, such as ClothingProduct, and forget to add it to CalculateTax, the application still compiles. I only find out there's an error when it throws an ArgumentOutOfRangeException at runtime.

public record ClothingProduct(
	int Id,
	string Name,
	decimal Price,
	string Size)
	: Product(Id, Name, Price);

Union types

C# 15 fixes this with union types. A union type lets you say that a value is one of a fixed list of types. Here, I've added a StockedProduct union type, which can be either a BookProduct or an ElectricalProduct.

public readonly union StockedProduct(
	BookProduct,
	ElectricalProduct);

I can then change the parameter type in CalculateTax to StockedProduct. As the compiler knows it can only be a BookProduct or an ElectricalProduct, I can remove the default case.

public static class UnionTypes
{
	public static decimal CalculateTax(StockedProduct product)
		=> product switch
		{
			BookProduct => 0m,
			ElectricalProduct electrical => electrical.Price * 0.2m,
		};
}

Now, when I add ClothingProduct to the StockedProduct union type, I get a warning saying that the switch expression doesn't handle all possible values.

public readonly union StockedProduct(
	BookProduct,
	ElectricalProduct,
	ClothingProduct);

CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'ClothingProduct' is not covered.

Turning the warning into an error

The good thing is that I can change the warning into an error. I do that by adding CS8509 to the WarningsAsErrors property in the .csproj file.

<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
	...
		<WarningsAsErrors>CS8509</WarningsAsErrors>
	</PropertyGroup>
	...
</Project>

Now, the application won't compile until I add ClothingProduct to the switch expression.

public static class UnionTypes
{
	public static decimal CalculateTax(StockedProduct product)
		=> product switch
		{
			BookProduct => 0m,
			ElectricalProduct electrical => electrical.Price * 0.2m,
			ClothingProduct clothingProduct => clothingProduct.Price * 0.1m
		};
}

Types don't need to share a base class

The other thing I like about union types is that the types don't have to derive from the same type. I could remove the inheritance of Product from ClothingProduct, and still use it in the StockedProduct union type.

You need to update to .NET 11 to use union types, and if I did update, this would be one of the features I'd update for.

Closed hierarchies

The closed keyword is the next feature. Adding it to a class or record means that only types within the same assembly can derive from it.

In this example, the Product record has the closed keyword, and the HomeProduct record derives from it. Both are in the OtherLibrary project.

public closed record Product(
	int Id,
	string Name,
	decimal Price);
public record HomeProduct(
	int Id,
	string Name,
	decimal Price,
	string Room)
	: Product(Id, Name, Price);

In the Library project, I've added an AutomotiveProduct record that tries to derive from Product.

public record AutomotiveProduct(
	int Id,
	string Name,
	decimal Price,
	string Manufacturer)
	: Product(Id, Name, Price);

As Product is in a different assembly, I get a compile error and the application won't compile.

CS9382: 'AutomotiveProduct': cannot use a closed type 'Product' from another assembly as a base type.

Using closed with union types

I think the closed keyword will work well with union types. If someone in another assembly derives from BookProduct, the compiler won't warn me about it. It would be treated as a BookProduct, which might not be what I want. Adding the closed keyword stops types in other assemblies deriving from it, so I know every type that can end up in the union.

One of the best use cases is likely to be NuGet packages. As well as controlling the types in a union, adding the closed keyword gives the people consuming your package a complete list of the possible values.

But as I don't write NuGet packages, it's not a feature I'm likely to use.

Extension indexers

Extension members were one of my favourite features of C# 14. If you had several extension methods with a common parameter, you could put them in an extension block. This made the code a lot tidier and easier to read.

public static class ProductExtensions
{
	extension(IEnumerable<Product> products)
	{
		public Product? GetById(int id)
			=> products.FirstOrDefault(p => p.Id == id);

		public Product? GetByName(string name)
			=> products.FirstOrDefault(p => p.Name == name);

		public IReadOnlyList<Product> HigherThanPrice(decimal price)
			=> products.Where(x => x.Price > price).ToList();
	}
}

C# 15 adds indexers to extension blocks, hence the name extension indexers. You add the this keyword, pass in the parameters and return the value.

You then use it in the same way as any other indexer, by adding the parameter in square brackets after the collection. The Get method in this example does exactly that.

public static class ProductExtensions
{
	extension(IEnumerable<Product> products)
	{
		// Extension indexers
		public Product? this[string name]
			=> products.FirstOrDefault(p => p.Name == name);

		public Product? Get(string name) => products[name];
	}
}

I don't use indexers as much as I'd like to, but this might encourage me to.

Collection expression arguments

Collection expressions were added in C# 12 and brought the spread operator to C#. What I love about them is that you can join different collection types together to form a new one. In this example, I'm adding a new product to an array of products and returning it as a list.

public static class ProductExtensions
{
	public static IList<Product> AddProduct(Product[] products,
		Product newProduct)
	{
		return [.. products, newProduct];
	}
}

But one thing they were lacking is that you couldn't pass any parameters into the constructor. With collection expression arguments, you can. You add with as the first element and pass in the parameters. Here, I'm setting the capacity of the list, and it works in the same way.

public static class ProductExtensions
{
    ...
 
	public static IList<Product> AddProductWithCapacity(Product[] products,
		Product newProduct)
	{
		return [with(capacity: products.Length + 1), ..products, newProduct];
	}
}

Labeled break and continue

The next feature is a resolution to a problem that I don't think should exist.

Here is an example of a nested loop. It loops through each order, and then loops through each product in the order to see if any are out of stock. If a product is out of stock, it sets inStock to false and breaks out of the inner loop. It then continues to the next order. Otherwise, it dispatches the order.

public static class ProductExtensions
{
	public static void DispatchOrders(
		IList<Order> orders, Action<Order> dispatch)
	{
		foreach (var order in orders)
		{
			var inStock = true;

			foreach (var product in order.Products)
			{
				if (!product.InStock)
				{
					inStock = false;
					break;
				}
			}

			if (!inStock)
			{
				continue;
			}

			dispatch(order);
		}
	}
}

Labeled break and continue gives you a resolution to this. You can add a label to a loop, and then append that label whenever you call break or continue. Rather than going to the next iteration of the inner loop, it goes to the next iteration of the labeled loop.

Here, I've given the outer loop a label of nextOrder.

public static void DispatchOrders(IList<Order> orders, Action<Order> dispatch)
{
	nextOrder: foreach (var order in orders)
	{
		foreach (var product in order.Products)
		{
			if (!product.InStock)
			{
				continue nextOrder;
			}
		}

		dispatch(order);
	}
}

I think it's bad practice to have nested loops in your code. If you need to query collection types, I think it's better to use LINQ. I would certainly consider refactoring my code before using this feature.

Is it worth updating to .NET 11?

With these features, I don't think I would update an existing application to .NET 11.

.NET 11 is a standard term support (STS) release, so it only has a two year support window. That's better than the 18 month support window that STS releases used to have. But as support for .NET 10 and .NET 11 ends on the same date, updating to .NET 11 would mean doing two separate updates instead of one.

Updating an application is very time consuming. As well as updating the project, I have to update the NuGet packages and do a lot of testing to make sure nothing has broken. It never works first time, and these new features don't warrant the time.

But if you're creating a new application, or you have an application that's out of support, you might as well use .NET 11. The support end date is the same as .NET 10, so you might as well take advantage of the new features.

Setting the language version to C# 15

If you have the .NET 11 SDK installed, you could stay on .NET 10 and set LangVersion to 15.0 in your .csproj file. But not all of the new features will work.

You can't use union types or closed hierarchies, because they rely on the .NET runtime. Using a union type gives these errors.

CS0518: Predefined type 'IUnion' is not defined or imported CS0656: Missing compiler required member 'UnionAttribute..ctor'

And using the closed keyword gives this error.

CS0656: Missing compiler required member 'IsClosedTypeAttribute..ctor'

If you want to use them, you'll have to update to .NET 11.

Watch the video

Watch the video where I go through the new features in C# 15, and discuss whether it's worth updating to .NET 11.