# Three C# 15 Features for Collections and Nested Loops

> Pass a comparer to a collection expression, add an indexer to an existing type, or break out of two loops at once. All three work in C# 15 on .NET 11.

Published: 2026-11-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/csharp-15-new-features

C# 15 ships with [**.NET 11**](https://milanjovanovic.tech/blog/whats-new-dotnet-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.

```csharp
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**](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-15#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**](https://milanjovanovic.tech/blog/5-ways-to-check-for-duplicates-in-collections).
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`.

```csharp
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**](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-15#extension-indexers) 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`.

```csharp
(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.

```text
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`**](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-15#labeled-break-and-continue) 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.
