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

What's New in C# 15: Union Types, Closed Hierarchies and Collection Arguments

Mert Özen Nov 14, 2026 13 min 11 views
What's New in C# 15: Union Types, Closed Hierarchies and Collection Arguments

C# 15 next to its C# 10 and C# 14 equivalents: with(...) for comparers and capacity in collection expressions, union types and the default-value trap, closed hierarchies with exhaustive switches and no default arm, extension indexers, labeled break/continue, static interface members on older runtimes and the memory safety preview.

Version note: This post covers the language features that arrived with C# 15; the code targets C# 15 / .NET 11 and was built and run with the .NET 11 SDK (Software Development Kit, 11.0.100).

Guess First

.NET 11, and C# 15 with it, reached GA (General Availability) on November 10, 2026. The first question comes from the feature you will meet most often in everyday code. We put the same three titles into two separate HashSet<string> instances. Which two numbers does the first line print? What does the second line print?

HashSet<string> plain = ["Clean Code", "CLEAN CODE", "clean code"];
HashSet<string> ignoreCase = [with(StringComparer.OrdinalIgnoreCase), "Clean Code", "CLEAN CODE", "clean code"];

Console.WriteLine($"{plain.Count} {ignoreCase.Count}");
Console.WriteLine(ignoreCase.Contains("CLEAN code"));

Collection Expression Arguments

The output:

3 1
True

with(...) is written as the first element of a collection expression and passes its arguments to the collection's constructor. Here the HashSet<string> constructor that takes a comparer was called; a comparer that ignores case collapsed the three titles into a single element. The first set has no comparer, so all three stayed separate. In C# 10 you built the same set like this:

var ignoreCase = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Clean Code", "CLEAN CODE", "clean code" };

When C# 12 introduced collection expressions, this line could not move to the square-bracket form, because there was no way to pass arguments to the constructor. The moment you needed a comparer or a capacity, you went back to the old form, and two styles sat side by side in the same file. C# 15 closes that gap. A few more examples from the Bookstore:

Book[] catalog =
[
    new("978-0132350884", "Clean Code", 42.5m),
    new("978-0134757599", "Refactoring", 38m),
    new("978-0321125217", "Domain-Driven Design", 55m),
];

List<Book> cart = [with(capacity: catalog.Length * 2), .. catalog];
Console.WriteLine($"{cart.Count} / {cart.Capacity}");

Shelf shelf = [with("A1"), .. catalog];
Console.WriteLine($"{shelf.Code}: {shelf.Count} books");

IList<Book> wishlist = [with(capacity: 16)];
Console.WriteLine(wishlist.GetType().Name);

public record Book(string Isbn, string Title, decimal Price);

public class Shelf(string code) : List<Book>
{
    public string Code { get; } = code;
}
3 / 6
A1: 3 books
List`1

For cart we called the capacity constructor of List<T> with a named argument. Shelf has no parameterless constructor; before C# 15 it could not be created with a collection expression, and now we pass the shelf code with with("A1"). For an interface target such as IList<Book> the compiler creates a List<T> behind the scenes and hands it the capacity. For your own types with a builder declared through the CollectionBuilder attribute, the arguments go to the Create method.

The with arguments are evaluated first, then the elements from left to right. Compiling with C# 14 gives: error CS9327: Feature 'collection expression arguments' is not available in C# 14.0. Please use language version 15.0 or greater. For the other features in this post C# 14 gives the same CS9327 error, only the feature name in quotes changes; older versions reject them as well.

Union Types

A payment either succeeds or gets declined. In C# 10 the common way to model that was a shared abstract record:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Console.WriteLine(Describe(Charge(80.5m)));
Console.WriteLine(Describe(Charge(250m)));

static PaymentResult Charge(decimal total) =>
    total <= 100m ? new Paid("tx-1001", total) : new Declined("limit exceeded");

static string Describe(PaymentResult result) => result switch
{
    Paid p => $"paid {p.Amount} ({p.TransactionId})",
    Declined d => $"declined: {d.Reason}",
};

public abstract record PaymentResult;
public record Paid(string TransactionId, decimal Amount) : PaymentResult;
public record Declined(string Reason) : PaymentResult;

The code works, but the compiler gives a warning:

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

From the compiler's point of view it is right: PaymentResult is an open base class, and tomorrow another assembly could derive a new type from it. An arm like _ => throw ... silences the warning, but from then on the compiler can tell you nothing. On top of that, to keep the two types together you have to put them into the same inheritance tree. The union type in C# 15 solves both problems at once:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Console.WriteLine(Describe(Charge(80.5m)));
Console.WriteLine(Describe(Charge(250m)));

PaymentResult result = Charge(80.5m);
Console.WriteLine(result.Value);
Console.WriteLine(result.GetType().IsValueType);

static PaymentResult Charge(decimal total) =>
    total <= 100m ? new Paid("tx-1001", total) : new Declined("limit exceeded");

static string Describe(PaymentResult result) => result switch
{
    Paid p => $"paid {p.Amount} ({p.TransactionId})",
    Declined d => $"declined: {d.Reason}",
};

public record Paid(string TransactionId, decimal Amount);
public record Declined(string Reason);

public union PaymentResult(Paid, Declined);
paid 80.5 (tx-1001)
declined: limit exceeded
Paid { TransactionId = tx-1001, Amount = 80.5 }
True

No warning this time. Paid and Declined are two independent records with no shared base class. The line union PaymentResult(Paid, Declined) declares the two types as a list of "case types". There is an implicit conversion from each case type to the union, which is why Charge can return new Paid(...) directly. The Paid p pattern in the switch tests the value inside the union, not the union itself. Because the list is closed, the compiler knows two arms are enough.

The last two lines show what happens behind the scenes. The compiler turns the union into a struct that carries the [Union] attribute and implements the IUnion interface; the content lives in a Value property of type object?. You see the real gain when you add a new case to the list:

public record Pending(string Step);

public union PaymentResult(Paid, Declined, Pending);
Program.cs(8,56): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Pending' is not covered.

The warning now names the missing case. Every switch in your project lights up until it handles the new state. If you ignore the warning and call it with Pending, you get a SwitchExpressionException at runtime.

Case types do not have to be classes either. Imagine we can look up a book either by its ISBN or by a numeric id left over from the old system:

BookKey byIsbn = "978-0132350884";
BookKey byLegacyId = 1042;

Console.WriteLine(Find(byIsbn));
Console.WriteLine(Find(byLegacyId));
Console.WriteLine(byLegacyId.Value?.GetType().Name);
Console.WriteLine(byIsbn is string);

static string Find(BookKey key) => key switch
{
    string isbn => $"lookup by ISBN {isbn}",
    int id => $"lookup by legacy id {id}",
};

public union BookKey(string, int);
lookup by ISBN 978-0132350884
lookup by legacy id 1042
Int32
True

There is a small cost: the int value is boxed because it is stored in an object? slot. If that matters in a hot loop, you can write the union yourself with the [Union] attribute and remove the boxing with HasValue and TryGetValue members.

What Is Inside a Union: null and default

A union is a struct, so like every struct it has a default value, and that value is empty inside. Look at this code:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var results = new PaymentResult[2];
results[0] = new Paid("tx-1001", 80.5m);

foreach (PaymentResult r in results)
    Console.WriteLine(Describe(r));

static string Describe(PaymentResult result) => result switch
{
    Paid p => $"paid {p.Amount}",
    Declined d => $"declined: {d.Reason}",
};

public record Paid(string TransactionId, decimal Amount);
public record Declined(string Reason);

public union PaymentResult(Paid, Declined);

It compiles cleanly, without a single warning. Running it:

paid 80.5
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was PaymentResult.

The second element of the array is default(PaymentResult); its Value is null. None of the case types is nullable, so the compiler treats Value as "not null" and does not warn about the missing null arm. Wherever the default value can leak in (arrays, uninitialized fields, default), add an explicit null arm to the switch:

static string Describe(PaymentResult result) => result switch
{
    Paid p => $"paid {p.Amount}",
    Declined d => $"declined: {d.Reason}",
    null => "no result",
};
paid 80.5
no result

The reason to choose a null arm over _ is to keep the warning for the case you add tomorrow; _ swallows everything. And do not try to test the union itself with a pattern: the line byIsbn is BookKey gave a CS8121 error, because type patterns always look at the content. To ask whether there is any content, write is not null.

Closed Hierarchies

A union brings independent types together. If the types are yours and it makes sense for them to share a base class, the second big feature of C# 15 is the more natural fit: the closed modifier.

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

OrderStatus[] statuses =
[
    new Placed(),
    new Shipped(new DateOnly(2026, 11, 12)),
    new Cancelled("out of stock"),
];

foreach (OrderStatus status in statuses)
    Console.WriteLine(Describe(status));

static string Describe(OrderStatus status) => status switch
{
    Placed => "placed",
    Shipped(var date) => $"shipped on {date:yyyy-MM-dd}",
    Cancelled(var reason) => $"cancelled: {reason}",
};

public closed record OrderStatus;
public record Placed : OrderStatus;
public record Shipped(DateOnly Date) : OrderStatus;
public record Cancelled(string Reason) : OrderStatus;
placed
shipped on 2026-11-12
cancelled: out of stock

Had you written the same code with abstract instead of closed, you would get the CS8509 warning from the payment example again; with closed there is no warning. You can derive directly from a closed class only inside the same assembly; the compiler knows the list of direct subtypes at compile time and accepts the three arms as enough. Delete the Cancelled arm and CS8509 comes back, this time naming 'Cancelled' as the missing pattern.

To see the rule, I moved OrderStatus into a separate library project (Bookstore.Orders) and tried to derive from it in the application project:

using Bookstore.Orders;

OrderStatus status = new Returned("damaged");
Console.WriteLine(status);

public record Returned(string Reason) : OrderStatus;
Program.cs(6,15): error CS9382: 'Returned': cannot use a closed type 'OrderStatus' from another assembly as a base type.

There are a few more rules. A closed class is implicitly abstract: new OrderStatus() gives a CS0144 error. You cannot combine closed with sealed (CS9381). Most importantly, closedness is not inherited: in the same application project public record PartiallyCancelled(string Reason, int Items) : Cancelled(Reason); compiled without complaint and printed PartiallyCancelled { Reason = customer request, Items = 2 }. If you do not want Cancelled to be extended from outside either, make it sealed or closed.

The short answer to which one to choose is this. If the types already exist and are independent of each other, or are types you do not own such as string and int, use a union. If the types are part of your domain and share data or behavior, use a closed hierarchy. A closed hierarchy is made of ordinary classes, so there is no boxing concern either.

Extension Indexers

C# 14 brought properties, static members and operators to extension blocks; the missing piece was indexers. In C# 10 you wrote an extension method to find a book by ISBN:

public static class CatalogExtensions
{
    public static Book? FindByIsbn(this IEnumerable<Book> books, string isbn) =>
        books.FirstOrDefault(b => b.Isbn == isbn);
}

In C# 15 the same thing can be an indexer:

IEnumerable<Book> catalog =
[
    new("978-0132350884", "Clean Code", 42.5m),
    new("978-0134757599", "Refactoring", 38m),
];

Console.WriteLine(catalog["978-0134757599"]?.Title);
Console.WriteLine(catalog["000"]?.Title ?? "not found");

List<Book> list = [.. catalog];
Console.WriteLine(list["978-0132350884"]?.Title);
Console.WriteLine(list[0].Title);

public record Book(string Isbn, string Title, decimal Price);

public static class CatalogExtensions
{
    extension(IEnumerable<Book> books)
    {
        public Book? this[string isbn] => books.FirstOrDefault(b => b.Isbn == isbn);
    }
}
Refactoring
not found
Clean Code
Clean Code

The interesting part is the last two lines. List<Book> has its own int indexer; since that indexer does not apply to a string argument, the compiler moved on to the extension indexer, while list[0] still went to the list's own indexer. An indexer is always an instance member, so naming the receiver (books) in the extension block is mandatory.

A word of caution: there is a linear search behind this indexer. catalog["..."] looks like a dictionary lookup but scans the list from the start on every call. Do not write an indexer that misleads the reader; if you will search a lot, build a Dictionary<string, Book> first.

Labeled break and continue

Getting out of a nested loop is a problem we have known since the C# Fundamentals series. To find the first order that contains an out-of-stock book, in C# 10 you kept a flag variable:

var stock = new Dictionary<string, int> { ["Clean Code"] = 3, ["Refactoring"] = 0, ["Domain-Driven Design"] = 5 };
var orders = new List<Order>
{
    new(1001, new[] { "Clean Code", "Domain-Driven Design" }),
    new(1002, new[] { "Refactoring", "Clean Code" }),
    new(1003, new[] { "Domain-Driven Design" }),
};

Order? blocked = null;
foreach (Order order in orders)
{
    bool found = false;
    foreach (string title in order.Titles)
    {
        if (stock[title] == 0)
        {
            found = true;
            break;
        }
    }

    if (found)
    {
        blocked = order;
        break;
    }
}

Console.WriteLine($"first blocked order: {blocked?.Id}");

public record Order(int Id, string[] Titles);

The other option was goto. In C# 15, break and continue can take the label of an enclosing loop:

Dictionary<string, int> stock = new() { ["Clean Code"] = 3, ["Refactoring"] = 0, ["Domain-Driven Design"] = 5 };
List<Order> orders =
[
    new(1001, ["Clean Code", "Domain-Driven Design"]),
    new(1002, ["Refactoring", "Clean Code"]),
    new(1003, ["Domain-Driven Design"]),
];

Order? blocked = null;
orders:
foreach (Order order in orders)
{
    foreach (string title in order.Titles)
    {
        if (stock[title] == 0)
        {
            blocked = order;
            break orders;
        }
    }
}

Console.WriteLine($"first blocked order: {blocked?.Id}");

List<int> shippable = [];
nextOrder:
foreach (Order order in orders)
{
    foreach (string title in order.Titles)
    {
        if (stock[title] == 0)
            continue nextOrder;
    }

    shippable.Add(order.Id);
}

Console.WriteLine($"shippable: {string.Join(", ", shippable)}");

public record Order(int Id, string[] Titles);
first blocked order: 1002
shippable: 1001, 1003

Both versions say 1002, but the new one has no flag. break orders leaves the outer loop entirely, and continue nextOrder moves to the next iteration of the outer loop. The label goes directly in front of the loop (or a switch statement); break and continue without a label still target the innermost loop as before. Put the label on some other line instead of the loop and the compiler stops you with CS9393.

Older Runtimes and the Memory Safety Preview

Two more items are on the list. The first one matters to library authors. Interfaces have allowed static members since C# 8, but the feature required a runtime with default interface implementation support, so a project targeting .NET Framework could not use it. C# 15 lifts that restriction for non-virtual static members:

using System;

Console.WriteLine(IPriced.WithVat(100m, 0.20m));

public interface IPriced
{
    decimal Price { get; }

    public static decimal WithVat(decimal price, decimal rate) => price * (1 + rate);
}

I compiled this code with C# 15 while TargetFramework was net48, and it went through without a problem. The same project with LangVersion 14 gave CS9327. In a library that targets both .NET Framework and modern .NET, you can now keep a helper method next to the interface. static abstract/static virtual members and instance default implementations still need runtime support.

The second one is a preview: the .NET 11 SDK brings the first step of a memory safety effort, spanning several releases, that ties the need for unsafe to operations that actually access memory rather than to the mere presence of a pointer type. In plain C# 15 the following code gives a CS0214 error (pointers may only be used in an unsafe context) three times:

int stock = 42;
int* pointer = &stock;
Console.WriteLine(pointer != null);

With LangVersion preview and AllowUnsafeBlocks turned on, the same code compiled and printed True. Reading the memory the pointer points to (*pointer) still requires unsafe. The preview language version is not suitable for production code; try it only to see where things are heading.

Common Pitfalls

Forgetting the default value of a union. As you saw above, the compiler does not warn you and you get a SwitchExpressionException at runtime. Write a null arm for values coming from arrays, fields and default.

Expecting a meaningful error on an older LangVersion. Unlike the other features, the union line does not produce a "feature not available" error in C# 14; the compiler does not recognize the line and lists these errors instead:

Program.cs(24,1): error CS0106: The modifier 'public' is not valid for this item
Program.cs(24,1): error CS8803: Top-level statements must precede namespace and type declarations.
Program.cs(24,32): error CS1001: Identifier expected
Program.cs(24,42): error CS1001: Identifier expected

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

Calling a method named with inside a collection expression. The line string[] titles = [with("Clean Code"), "Refactoring"]; gave error CS9355: 'with(...)' elements are not supported for type 'string[]' even though the same file had a local function named with; with the .NET 11 SDK it did so on LangVersion 14 as well. If you mean the method, write @with(...).

Underestimating the new contextual keywords. In the previous post, the CS8981 warning said type names in all lowercase may become reserved in the future. In C# 15 that happened: public class closed { } now gives error CS9380: Types and aliases cannot be named 'closed'., and there is a similar rule for union. If you have such a type, rename it or write @closed.

A Small Experiment

★☆☆ Add a Refunded(decimal Amount) case to the PaymentResult union. How many CS8509 warnings show up in the project? Fix all of them without using _.

★★☆ Add a second constructor to Shelf that takes a shelf code and a capacity, and use it with [with("A1", 32), .. catalog]. Does overload selection work correctly based on the with arguments?

★★★ Write BookKey yourself with the [Union] attribute; add HasValue and two TryGetValue methods to remove boxing for the int case. Does the switch code change?

That brings us to C# 15. .NET 11 is an STS (Standard-Term Support) release; according to Microsoft's support table its support ends on November 14, 2028, the same day as the LTS (Long-Term Support) release .NET 10. If you are moving an existing project, the steps in the previous post apply to .NET 11 as well. The series will continue the same way with the next language version.