C# 15 Preview!

Sakthivel     16/08/2026     9

C# 15 Preview - Collection expression arguments

Introduction

C# 15 is still in preview, but a handful of its features already change the way I write everyday code. This post walks through four of them: collection expression arguments, union types, labelled break and continue, and closed hierarchies. Each one solves a small, familiar annoyance, and each one is worth trying out before it lands in a production coding convention.

Part 1: Collection expressions just got useful

I have liked collection expressions since they were introduced. This is clean and easy to read:

List<string> names = [.. values];

However, they have always had one limitation: they let you describe what goes in the collection but not how the collection should be created.

For example, if I know that a list will grow, I suggest allocating extra capacity up front. Or a HashSet that treats CSharp, csharp, and CSHARP as the same value.

var tags = new HashSet<string>(
    StringComparer.OrdinalIgnoreCase)
{
    "CSharp",
    "csharp",
    "CSHARP"
};

Console.WriteLine(string.Join(", ", names));
Console.WriteLine(tags.Count); // 1

Collection expressions make the contents concise, but they cannot include details such as the initial capacity or comparer.

C# 15 Preview introduced a small addition that fills the gap: with(…)

string[] values = ["one", "two", "three"];

List<string> names =
[
    with(capacity: values.Length * 2),
    .. values
];

HashSet<string> tags =
[
    with(StringComparer.OrdinalIgnoreCase),
    "CSharp",
    "csharp",
    "CSHARP"
];

Console.WriteLine(string.Join(", ", names));
Console.WriteLine(tags.Count); // 1

What I like most is that the code is shorter. The collection’s content and the way it should be constructed now appear in the same place. You can immediately see the capacity decision or equality rule without having to find an earlier constructor call.

I would not specify the capacity for each list. This would add unnecessary noise. However, when profiling supports preallocation, or when a comparer is an important part of the collection’s behaviour, this syntax feels like a natural fit.

C# 15 is still in preview, and the tentative date for production is November 2026; therefore, it is too early to make this a production coding convention. Still, it is worth experimenting with.

Source Code

https://github.com/sakthivel0531/C-15Preview/tree/main/csharp-13-14-15-features

Try it out

Install the latest .NET 11 Preview from the Microsoft website.

https://dotnet.microsoft.com/en-us/download/dotnet/11.0

Create a new Dot Net 11 project and add the following attributes under the project group in the project file.

<PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <LangVersion>preview</LangVersion>
</PropertyGroup>

C# 15 Preview Part 1 - Collection expression arguments summary

Part 2: Union types finally make valid outcomes explicit

I often encounter the same modelling problem when designing APIs.

A method can return one of the valid outcomes: the payment was successful, the request was declined, or the customer should try again later.

The code understands these possibilities, but the type system does not necessarily know that the list is complete.

In C#13 and C#14, I would typically model this with a record hierarchy.

public abstract record PaymentResult;
public sealed record Paid(string ReceiptId) : PaymentResult;
public sealed record Declined(string Reason) : PaymentResult;
public sealed record RetryLater(TimeSpan Delay) : PaymentResult;

That is perfectly reasonable code. However, there is a subtle gap when I consume it.

string Message(PaymentResult result) => result switch
{
    Paid p => $"Receipt: {p.ReceiptId}",
    Declined d => d.Reason,
    RetryLater r => $"Retry in {r.Delay.TotalMinutes} min",
    _ => "Unknown result"
};

I still need a fallback because another type can be derived from PaymentResult.

If someone adds a new outcome later, the compiler can not reliably tell me that this switch needs attention.

public record Paid(string ReceiptId);
public record Declined(string reason);
public record RetryLater(TimeSpan Delay);

public union PaymentResult(
    Paid,
    Declined,
    RetryLater);

Now, the type itself specifies exactly what a payment result may contain.

string Message(PaymentResult result) => result switch
{
    Paid p => $"Receipt: {p.ReceiptId}",
    Declined d => d.Reason,
    RetryLater r => $"Retry in {r.Delay.TotalMinutes} min"
};

This is what makes this feature interesting to me. It is not just a keyword; it makes maintenance safer.

Add a fourth result, and the code that needs to be understood becomes visible at compile time.

This does not mean that every inheritance hierarchy should become a union. If other teams or libraries need to extend the model, a closed set is probably a wrong choice.

However, when the alternatives are finite, a union makes the contract much cleaner.

Source Code

https://github.com/sakthivel0531/C-15Preview/tree/main/csharp-13-14-15-features

Try it out

Install the latest Dot Net 11 Preview from the Microsoft site.

https://dotnet.microsoft.com/en-us/download/dotnet/11.0

Create a new Dot Net 11 project and add the following attributes under the project group in the project file.

<PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <LangVersion>preview</LangVersion>
</PropertyGroup>

C# 15 Preview Part 2 - Union types summary

Part 3: Labelled break and continue

Nested loops are easy to write. Getting out of the correct loop is where things get awkward.

In C#13 and C#14, a regular break exists only in the innermost loops.

bool found = false;
for (int row = 0; row < rows; row++)
{
    for (int column = 0; column < columns; column++)
    {
        if (grid[row, column] == 9)
        {
            Console.WriteLine($"Found at {row}, {column}");
            found = true;
            break;
        }
    }
    if (found)
        break;
}

The found flag has nothing to do with the problem. It is there purely to handle the control flow.

Add a third nested loop, and the pattern becomes even more cumbersome: set the flag, exit the inner loop, check the flag, exit the next loop, and repeat.

Although the flag could be avoided with a goto, many teams are understandably cautious when they encounter one in a code review.

C# 15 introduces a preview feature that allows breaking to target a named group.

search:
for (int row = 0; row < rows; row++)
{
    for (int column = 0; column < columns; column++)
    {
        if (grid[row, column] == 9)
        {
            Console.WriteLine($"Found at {row}, {column}");
            break search;
        }
    }
}

The label was placed directly on the loop that was targeted.

break search;

The intent is clear: stop the loop named search.

There is an equivalent form for continue.

Assume that each outer-loop iteration represents an order. If any item is invalid, we want to skip the remaining items and avoid running the completion logic for that order.

orders:
foreach (Order order in batch)
{
    foreach (Item item in order.Items)
    {
        if (!item.IsValid)
            continue orders;
        Process(item);
    }
    Complete(order);
}

Here, complete orders do not move to the next item. It starts the next iteration of the outer order loop.

What I like about this feature is that the destination is visible at the jump itself. I do not have to follow a Boolean flag through several levels of nesting to determine which loop will stop or continue.

Of course, this is not an excuse to create deeply nested methods.

If a block is performing too many tasks, extracting it into a separate method and using return may still be the clearest option. Labelled jumps are useful when the nested loops genuinely belong to a single operation, not as a replacement for good decomposition.

Could this feature remove a Boolean flag — or perhaps a goto — from any nested loops in your codebase?

C# 15 is still in preview, and the tentative date for production is November 2026; therefore, it is too early to make this a production coding convention. Still, it is worth experimenting with.

Source Code

https://github.com/sakthivel0531/C-15Preview/tree/main/csharp-13-14-15-features

Try it out

Install the latest Dot Net 11 Preview from the Microsoft site.

https://dotnet.microsoft.com/en-us/download/dotnet/11.0

Create a new Dot Net 11 project and add the attributes listed below under the project group in the project file.

<PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <LangVersion>preview</LangVersion>
</PropertyGroup>

C# 15 Preview Part 3 - Labelled break and continue summary

Part 4: Closed hierarchies make inheritance more predictable

I like inheritance when it represents a genuine relationship between types.

Order processing is a good example. An order can be pending, shipped, delivered or cancelled.

In C# 13 and C# 14, I might represent those states using records:

public abstract record OrderState;
public sealed record Pending : OrderState;
public sealed record Shipped(string TrackingId)
    : OrderState;
public sealed record Delivered(DateTime DeliveredAt)
    : OrderState;
public sealed record Cancelled(string Reason)
    : OrderState;

At first glance, that looks like the complete set of possible order states. But it isn’t. OrderState itself is still open, so another derived type could be added later.

That uncertainty carries over into pattern matching:

string Describe(OrderState state) => state switch
{
    Pending       => "Waiting to ship",
    Shipped s     => $"Tracking: {s.TrackingId}",
    Delivered d   => $"Delivered: {d.DeliveredAt:d}",
    Cancelled c   => $"Cancelled: {c.Reason}",
    _             => "Unknown state"
};

The fallback is sensible defensive programming, but it also masks a potential problem.

If someone adds a genuine fifth state, this method will continue to compile. Instead of telling us that the switch needs updating, it will quietly return "Unknown state" until someone notices.

C# 15 preview adds the closed modifier:

public closed record class OrderState;
public record class Pending : OrderState;
public record class Shipped(string TrackingId) : OrderState;
public record class Delivered(DateTime DeliveredAt) : OrderState;
public record class Cancelled(string Reason) : OrderState;

A direct descendant of OrderState must be declared in the same assembly and module.

Because the compiler knows the complete set of direct descendants, the switch can be exhaustive as follows:

string Describe(OrderState state) => state switch
{
    Pending => "Waiting to ship",
    Shipped s => $"Tracking: {s.TrackingId}",
    Delivered d => $"Delivered: {d.DeliveredAt:d}",
    Cancelled c => $"Cancelled: {c.Reason}"
};

No fallback is needed.

The real value becomes clear when the hierarchy is changed. Suppose that we add another state:

public record class Returned(string Reason) : OrderState;

The compiler can now identify switches that do not handle Returned.

Instead of discovering the missing case in production, you get feedback while building the application. This is a much better time to have the conversation.

It is also important to understand that closed and sealed are not the same.

There is one more detail to remember: closure is not automatically inherited.

A descendant can still be extended from another assembly unless the descendant is also marked as closed or sealed.

C# 15 is still in preview, and the tentative date for production is November 2026; therefore, it is too early to make this a production coding convention. Still, it is worth experimenting with.

Source Code

https://github.com/sakthivel0531/C-15Preview/tree/main/csharp-13-14-15-features

Try it out

Install the latest Dot Net 11 Preview from the Microsoft site.

https://dotnet.microsoft.com/en-us/download/dotnet/11.0

Create a new Dot Net 11 project and add the attributes below under the project group in the project file.

<PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <LangVersion>preview</LangVersion>
</PropertyGroup>

C# 15 Preview Part 4 - Closed hierarchies summary

Conclusion

Collection expression arguments, union types, labelled break and continue, and closed hierarchies each remove a small piece of friction from everyday C# code. None of them are groundbreaking on their own, but together they make intent easier to read and let the compiler catch more of what used to be a manual review job.

C# 15 is still in preview, with a tentative production date of November 2026. That makes it too early to bake any of this into a production coding convention, but it is a good time to try these features out in a side project and see which ones earn a place in your own code.


Comments

No comments found.



    Leave a Comment