C# 15 is almost here.
And this time, it’s not just new syntax. Some of these changes will shift the way you model your code.
Here are my top 6.
1. Collection Expression Arguments
Collection expressions ([1, 2, 3]) were great until you needed a capacity or a comparer. Then you had to go back to the constructor.
A with(...) element, written first, forwards the arguments to the collection’s constructor or factory method:
string[] values = ["one", "two", "three"];
List<string> names = [with(capacity: values.Length * 2), .. values];
HashSet<string> set = [with(StringComparer.OrdinalIgnoreCase), "Hello", "HELLO", "hello"];
// set contains only one element: all strings are equal with OrdinalIgnoreCase
It also prepares the ground for the planned dictionary expressions.
2. Union Types
A union says, in the type system, exactly which types a value can hold. The case types don’t need a common base type.
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
Pet pet = new Dog("Rex"); // implicit conversion from each case type
string name = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};
No discard needed: the compiler knows the full set, and if you add a Lizard to Pet later, every switch that doesn’t handle it gets a warning.
One detail: a default union has a null Value, so prefer a null arm over _ when default values can reach your switch.
3. Closed Hierarchies
The same idea, applied to class hierarchies. A closed class can only be derived from inside its own assembly, so the compiler knows every direct descendant.
public closed record class GateState;
public record class Closed : GateState;
public record class Open(float Percent) : GateState;
string Describe(GateState state) => state switch
{
Closed => "closed",
Open(var percent) => $"{percent}% open",
// No warning: every direct descendant is handled.
};
A closed class is implicitly abstract. Derivation isn’t transitive, so mark intermediate types closed too if you want exhaustiveness further down.
4. Extension Indexers
C# 14 brought extension properties and operators. C# 15 adds indexers to the extension block.
public static class SequenceExtensions
{
extension(IEnumerable<int> sequence)
{
public int this[int index] => sequence.ElementAt(index);
}
}
IEnumerable<int> numbers = Enumerable.Range(1, 10);
int third = numbers[2];
Indexers are always instance members, so the extension block must have a named receiver (sequence here).
5. Labeled break and continue
Leaving a nested loop used to mean a Boolean flag, a goto or an extracted method.
scan: foreach (Warehouse warehouse in warehouses)
{
foreach (Bin bin in warehouse.Bins)
{
if (bin.Sku == requestedSku && bin.Quantity > 0)
{
reserved = Reserve(bin);
break scan;
}
}
}
continue scan; works the same way, jumping to the next iteration of the labeled loop. Without a label, break and continue keep their old behavior. The new IDE0410 rule flags the flag and goto patterns that can be replaced.
6. Memory Safety
C# 15 starts a multi-release effort to redefine memory safety. The unsafe context is now tied to the operations that actually access unmanaged memory, not to the existence of pointer types.
With the preview language version, these operations no longer require an unsafe context:
- Declaring a pointer type and taking an address with
& - The
fixedstatement - Converting a
stackallocexpression to a pointer sizeofon any unmanaged type
int number = 42;
int* pointer = &number;
int[] numbers = [10, 20, 30];
fixed (int* first = numbers)
{
// Dereferencing the pointer still requires an unsafe context.
}
Reading or writing through a pointer (*p, p->member, p[i]) and calling function pointers still need unsafe.
There’s also a new unsafe(expression), for places where an unsafe block can’t appear, like a field initializer:
class Header
{
static readonly int Signature = unsafe(ReadSignature());
static unsafe int ReadSignature()
{
int rawValue = 0x1234;
int* pointer = &rawValue;
return *pointer;
}
}
To try it, set LangVersion to preview and enable AllowUnsafeBlocks. To also turn on the updated rules, where unsafe on a member means its callers must be in an unsafe context, enable the updated-memory-safety-rules feature.
Conclusion
Unions and closed hierarchies are the headline: the compiler finally knows every case, so a new case becomes a compile-time warning instead of a runtime exception. The other features remove everyday ceremony.
If you are already testing the .NET 11 RC, give it a try. The official release is planned for November.
Which C# 15 feature would you use first?
Thank you for reading.
See you next time!



