Three C# 15 Features for Collections and Nested Loops

csharpdotnet
Three C# 15 Features for Collections and Nested Loops

C# 15 ships with .NET 11. You can now pass constructor arguments inside a collection expression, give an existing type another indexer, and target an outer loop with break or continue. If your project targets net11.0 with the .NET 11 SDK, you can use all three without setting LangVersion to preview.

Can a Collection Expression Set the Sort Order?

A support-ticket dispatcher needs each priority once, with the most urgent tickets first. A SortedSet<int> removes duplicates, but it puts priority 2 ahead of priority 5 by default. Pass it a descending comparer when you create the set.

Save this as Priorities.cs and run dotnet run --file Priorities.cs with the .NET 11 SDK.

int[] incoming = [2, 5, 2, 4];
var highestFirst = Comparer<int>.Create((left, right) => right.CompareTo(left));

SortedSet<int> priorities = [with(highestFirst), .. incoming];
Console.WriteLine(string.Join(", ", priorities));

The output is 5, 4, 2. The set removes the second 2, and its comparer puts 5 first. If you remove with(highestFirst), the set uses the default comparer and prints 2, 4, 5.

The square brackets and the .. spread were already available in C# 12. C# 15 lets you pass collection expression arguments to the target collection's constructor or factory. Put them inside with(...) before the first element or spread. Here the type on the left tells the compiler which constructor to use.

Use a HashSet<T> when you only need to detect duplicate entries. The dispatcher also needs to read the priorities in order.

Give the Rota a Two-Argument Indexer

The on-call rota maps a weekday and priority to a team. The library that supplies it exposes an IReadOnlyDictionary<(DayOfWeek, int), string>. A caller can already look up a tuple key with rota[(day, priority)].

With a C# 15 extension indexer, callers can write rota[day, priority] and leave the tuple construction to the accessor. Run this as dotnet run --file Rota.cs.

IReadOnlyDictionary<(DayOfWeek, int), string> rota =
    new Dictionary<(DayOfWeek, int), string>
    {
        [(DayOfWeek.Monday, 5)] = "Primary",
        [(DayOfWeek.Monday, 2)] = "Triage"
    };

Console.WriteLine(rota[DayOfWeek.Monday, 5]);

public static class RotaExtensions
{
    extension(IReadOnlyDictionary<(DayOfWeek, int), string> rota)
    {
        public string this[DayOfWeek day, int priority] => rota[(day, priority)];
    }
}

The output is Primary. The two-argument accessor builds a tuple and calls the dictionary's existing one-argument indexer. A missing key still throws KeyNotFoundException. If your caller expects an empty slot, use TryGetValue so that case stays visible in the calling code.

An extension indexer needs a named receiver (rota here), because each lookup runs against a particular instance. If you place RotaExtensions in a namespace, callers need that namespace in scope for the new syntax.

Stop at the First Responder, or Pick One per Team

An escalation policy checks teams in order. Within each team, it checks candidates until it finds someone on call. Once it finds the first responder, it can stop both loops.

Save this as Escalate.cs and run dotnet run --file Escalate.cs.

(string Team, string[] Responders)[] teams =
[
    ("Orders", ["Ana", "Tom"]),
    ("Payments", ["Jo", "Sam"]),
    ("Search", ["Mia"])
];
HashSet<string> onCall = ["Tom", "Jo", "Mia"];

escalation: foreach (var (team, responders) in teams)
{
    foreach (string name in responders)
    {
        if (!onCall.Contains(name)) continue;

        Console.WriteLine($"{team}: {name}");
        break escalation;
    }
}

The only line printed is Orders: Tom. The ordinary continue skips Ana and tries the next person on the same team. Because escalation labels the outer foreach, break escalation skips the other teams once it finds Tom.

Now suppose an incident needs one responder per team. Replace break escalation; with continue escalation; and run it again.

Orders: Tom
Payments: Jo
Search: Mia

The labeled continue skips the remaining candidates on the current team and starts the next team. Place the label directly on its target loop. A labeled break can also leave an enclosing switch. A labeled continue always targets a loop.

Summary

When you review a labeled jump, read the label and its target together. In the escalation loop, changing break escalation to continue escalation pages a responder from every team. That difference is easy to miss if you're used to break and continue affecting only the innermost loop.

Frequently Asked Questions

Do these C# 15 features require preview settings?

No. Target net11.0 with the .NET 11 SDK and its default language version for collection expression arguments, extension indexers, and labeled jumps.

Where does with(...) go in a C# collection expression?

Place it before the first element or spread. Its arguments configure the constructor or factory used for the target collection, such as a comparer for a SortedSet.

Can an extension indexer use more than one key?

Yes. Declare the indexer in an extension block with a named receiver and give the indexer multiple parameters. Its accessor can translate them into the key expected by the underlying collection.

What is the difference between break label and continue label?

A labeled break exits the enclosing loop or switch identified by the label. A labeled continue starts the next iteration of the enclosing loop identified by the label.

Browse all .NET guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.