C# 15 ships with .NET 11 in November, and most of the buzz has rightly gone to union types and closed hierarchies. Sadukie has those covered, so go check out her series Tiles & Tokens if you haven’t already. But the headline features aren’t the whole story. C# 15 also brings a handful of smaller changes that remove a lot of everyday friction, and a couple of them you’ll likely use more often than unions.
In this post, we’ll look at:
- Collection expression arguments, which let you pass a comparer or capacity directly in a collection expression
- Extension indexers, which round out the extension members feature from C# 14
- Labeled
breakandcontinue, for getting out of nested loops cleanly - The redesigned
unsafemodel, a preview of where memory safety in C# is headed
Collection Expression Arguments
Collection expressions arrived back in C# 12, and they quickly became the default way to create collections:
List<string> names = ["Alice", "Bob", "Carol"];
int[] numbers = [1, 2, 3];
HashSet<string> tags = [.. existingTags, "new-tag"];
They’re concise and readable, but they had one big gap. There was no way to pass arguments to the collection’s constructor. If you needed a HashSet<string> that ignores case, or a List<T> with a starting capacity, you had to go back to the old syntax:
var tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "dotnet", "csharp" };
var names = new List<string>(capacity: values.Length * 2);
names.AddRange(values);
That works fine, but it means your codebase ends up with two styles for creating collections, and the choice between them depends on whether you happen to need a constructor argument.
Enter with(...)
C# 15 fixes this with a new with(...) element. Put it first in a collection expression, and its arguments get passed to the collection’s constructor:
HashSet<string> tags = [with(StringComparer.OrdinalIgnoreCase), "dotnet", "DotNet", "csharp"];
// tags contains two items: "dotnet" and "csharp"
List<string> names = [with(capacity: values.Length * 2), .. values];
Named arguments work, as you can see with capacity: in the line above. And the arguments can be any valid expression.
Comparers
Comparers are one use-case where this feature can really stand out. Case-sensitivity bugs are some of the most annoying bugs to track down. Someone registers with Barret@Example.com, then tries to log in as barret@example.com, and the system returns a user not found error because the HashSet<string> or Dictionary was created with the default, case-sensitive comparer.
Using with(...) to pass in the StringComparer.OrdinalIgnoreCase parameter addresses this disconnect because the comparer lives right there in the declaration, in the same style as every other collection in your code:
public class RegisteredEmails
{
private readonly HashSet<string> _emails =
[with(StringComparer.OrdinalIgnoreCase)];
public bool Register(string email) => _emails.Add(email);
public bool IsRegistered(string email) => _emails.Contains(email);
}
var registered = new RegisteredEmails();
registered.Register("Barret@Example.com");
registered.IsRegistered("barret@example.com"); // true
That [with(StringComparer.OrdinalIgnoreCase)] is an empty set with a case-insensitive comparer. Short, readable, and the intent is obvious to the next developer.
Capacity
The capacity case is about performance. When a List<T> runs out of room, it allocates a bigger internal array and copies everything over. If you know roughly how many items you’re going to end up with, setting the capacity up front avoids those repeated allocations.
There’s a nuance here worth knowing, though. The compiler is already smart about collection expressions. If you spread a source whose count it knows, like an array or a List<T>, it pre-sizes the result for you. So this doesn’t buy you anything:
// The compiler already knows the final size here
List<string> names = [with(capacity: values.Length), .. values];
Where capacity helps is when you’re going to keep adding after the collection is created:
List<OrderLine> lines = [with(capacity: expectedLineCount)];
foreach (var item in cart.Items)
{
lines.Add(OrderLine.From(item));
}
As always with performance tweaks, measure before you assume. If you’re curious how to do that, we’ve covered benchmarking in .NET before.
Interfaces and Custom Collections
with(...) also works when the target is an interface, within limits. For IList<T> and ICollection<T>, the compiler creates a List<T> under the hood, so you can pass a capacity:
IList<int> numbers = [with(capacity: 100), 1, 2, 3];
For the read-only interfaces, IEnumerable<T>, IReadOnlyCollection<T>, and IReadOnlyList<T>, only an empty with() is allowed, since there’s no constructor for you to customize.
If you’ve built your own collection type with the [CollectionBuilder] attribute, the arguments in with(...) get passed to your create method, ahead of the ReadOnlySpan<T> parameter:
internal static class TagSetBuilder
{
internal static TagSet Create(ReadOnlySpan<string> items) => new(items);
internal static TagSet Create(IEqualityComparer<string> comparer, ReadOnlySpan<string> items) =>
new(items, comparer);
}
TagSet tags = [with(StringComparer.OrdinalIgnoreCase), "dotnet", "DOTNET"];
The compiler picks the create method through normal overload resolution, so you can offer as many overloads as make sense.
The Rules
A few restrictions to keep in mind:
with(...)must be the first element, and you can only have one.- It isn’t supported for arrays or span types, since there’s no constructor to pass anything to.
- Arguments can’t be of type
dynamic.
But what about dictionaries? Dictionary expressions (something like [with(StringComparer.OrdinalIgnoreCase), "key": value]) are on the roadmap, but they didn’t make it into C# 15. with(...) is the groundwork that makes them possible. So for now you’ll have to keep using collection initializers for your dictionaries.
Extension Indexers
C# 14 gave us extension members. This is the new extension block syntax that lets you add properties and static members to types you don’t own, not just methods. C# 15 adds a missing piece: indexers.
Let’s look at an example. In ASP.NET Core, getting a claim value off the current user is a little clunky:
var email = User.FindFirst(ClaimTypes.Email)?.Value;
var tenantId = User.FindFirst("tenant_id")?.Value;
ClaimsPrincipal doesn’t have an indexer, but now we can give it one:
public static class ClaimsPrincipalExtensions
{
extension(ClaimsPrincipal user)
{
public string? this[string claimType] => user.FindFirst(claimType)?.Value;
}
}
And now your endpoint code reads like this:
var email = User[ClaimTypes.Email];
var tenantId = User["tenant_id"];
The functionality hasn’t changed. You’re just making things a little easier to read. The syntax is exactly what you’d expect if you’ve already used extension blocks. The indexer goes inside the extension block, and the receiver parameter, user in this case, is available in the body. The receiver needs a name, and the indexer can’t be static.
The Rules of Precedence
Extension indexers are a fallback, not an override. The compiler checks for a matching indexer in this order:
- A real instance indexer on the type always wins.
- Next come the implicit
IndexandRangeindexers the compiler gives you for types that have anintindexer plus aCountorLengthproperty. That’s what makeslist[^1]work. - Only if neither of those matches does the compiler consider your extension indexer.
One more thing to watch out for: extension indexers on arrays and strings are ignored entirely. The compiler will let you declare one, but it will never be used. Those types already have built-in indexing, and that’s what you’ll get.
A Word of Caution
The official docs use this example:
extension(IEnumerable<int> sequence)
{
public int this[int index] => sequence.ElementAt(index);
}
int third = numbers[2];
It’s a nice demonstration of the syntax, but think twice before doing this in real code. An indexer looks like it’s cheap and instant, the way it is on an array or a List<T>. But ElementAt on a plain IEnumerable<T> might walk the whole sequence to get there. Hide that behind square brackets inside a loop, and you’ve written an O(n2) algorithm, which can potentially have a massive impact on performance. Worse, if that IEnumerable is a deferred EF Core query, every index access could execute it all over again.
Consider this rule of thumb: an extension indexer should be about as cheap as the indexers developers are used to. If it does real work, make it a method instead.
Labeled break and continue
This one is small, but if you’ve ever needed it, you’ll appreciate just how big it can be. We’ve all written code that searches through nested loops and needs to stop once it finds a match. The problem is that break only exits the innermost loop. So you end up with a flag variable:
Bin? reservedBin = null;
bool found = false;
foreach (var warehouse in warehouses)
{
foreach (var bin in warehouse.Bins)
{
if (bin.Sku == requestedSku && bin.Quantity > 0)
{
reservedBin = bin;
found = true;
break;
}
}
if (found)
{
break;
}
}
Or you reach for goto, and then explain yourself in code review. Or you extract the loops into a method just so you can return out of the middle.
In C# 15, you can label the loop and break out of it directly:
Bin? reservedBin = null;
search: foreach (var warehouse in warehouses)
{
foreach (var bin in warehouse.Bins)
{
if (bin.Sku == requestedSku && bin.Quantity > 0)
{
reservedBin = bin;
break search;
}
}
}
No flag, no extra check after the inner loop. break search; exits the loop labeled search, no matter how deeply nested you are. If you’ve written Java, Kotlin, Rust, or JavaScript, this will look familiar.
continue works the same way. continue nextOrder; skips to the next iteration of the labeled loop:
nextOrder: foreach (var order in orders)
{
foreach (var line in order.Lines)
{
if (line.IsBackordered)
{
// Skip the rest of this order entirely
continue nextOrder;
}
}
Ship(order);
}
Breaking Out of a switch Inside a Loop
Here’s a scenario that has tripped up plenty of developers: a switch statement inside a loop. Inside a switch, break exits the switch, not the loop:
foreach (var line in lines)
{
switch (line.Kind)
{
case LineKind.EndOfData:
break; // Only exits the switch. The loop keeps going!
default:
Process(line);
break;
}
}
With a label, the intent is unambiguous:
read: foreach (var line in lines)
{
switch (line.Kind)
{
case LineKind.EndOfData:
break read; // Exits the loop
default:
Process(line);
break;
}
}
The Rules
- A labeled
breakcan target any loop (for,foreach,while,do) or aswitchstatement. - A labeled
continuecan only target a loop. You can’tcontinueaswitch. - The label has to be directly on the loop or switch it refers to.
- The usual
finallyrules still apply. You can’tbreakorcontinueyour way out of afinallyblock, and anyfinallyblocks you exit along the way still run. Watch this one, because it can trip you up if you aren’t careful.
Should you use it everywhere? No. Deeply nested loops are often a sign that a method should be split up, and extracting a method with an early return is still a perfectly good answer. But when the nested loop really is the clearest way to express the logic, labeled break beats a flag variable or a goto every time.
Like I said: This is a small thing. But if you’ve ever needed something like this, it’s really, really big.
A Preview of the New unsafe Model
The last feature is one most application developers won’t touch directly, but it’s worth knowing where things are headed. C# 15 starts a multi-release redesign of what unsafe means.
Today, unsafe is basically a syntax marker that says “there are pointers in here.” Declare a pointer type, take an address with &, use fixed, and you need an unsafe context, even if you never actually read or write through the pointer.
The new model flips that around. unsafe becomes a contract: “this code does something the compiler can’t verify, and the developer is vouching for it.” Declaring pointers, taking addresses, using fixed, and converting stackalloc to a pointer no longer need unsafe. The operations that actually touch memory do:
int number = 42;
int* pointer = &number; // No unsafe context needed under the new rules
unsafe
{
Console.WriteLine(*pointer); // Dereferencing still requires unsafe
}
Pointer indirection (*p), member access through a pointer (p->member), element access (p[i]), and calling function pointers all still require an unsafe context. There’s also a new unsafe(expression) form for wrapping a single expression without a whole block.
There’s an important behavioral change too. Under the new rules, a member marked with the unsafe modifier can only be called from an unsafe context. In other words, unsafety becomes something that propagates to callers, rather than staying hidden inside the method.
This is a preview feature, and the design may change in C# 16. You have to opt in explicitly:
<PropertyGroup>
<LangVersion>preview</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Features>$(Features);updated-memory-safety-rules</Features>
</PropertyGroup>
I wouldn’t put this into production code yet. But if you maintain a library that does interop or low-level performance work, now is a good time to try it and give the team feedback on the Roslyn repo.
Try It Today
At the time of writing, .NET 11 is at Release Candidate 1, which comes with a go-live license. The full release is expected in November. Install the latest .NET 11 SDK and target net11.0. C# 15 is the default language version for .NET 11 projects, so collection expression arguments, extension indexers, and labeled break and continue work out of the box and you don’t need to explicitly declare the use of C# 15. Only the memory safety rules need the extra opt-in shown above.
Wrapping Up
None of these features will reshape how you architect an application the way union types might. But that’s not really the point. Collection expression arguments mean one consistent way to create collections. Extension indexers finish what C# 14 started with extension members. Labeled break retires a pattern we’ve all been writing for years. Small changes like these are what make a language nicer to work in day after day, and C# 15 has a good crop of them.

