C# 15 Preview – What’s New Before the Final Release

C# keeps moving forward with every .NET release, and C# 15 is no different. It ships officially with .NET 11 in November, but you don’t have to wait — every feature covered here is already available in .NET 11 Preview 7, so you can try it in your own projects today.

C# 15 brings six notable additions: union types, closed hierarchies, an early look at a redesigned memory-safety model, collection expression arguments, extension indexers, and labeled break and continue. Some are ready to use right away, one is still experimental, but all of them share a common goal — helping you write code where your intent is clear, both to other developers and to the compiler itself.

Let’s walk through each one, what problem it solves, and how it looks in real code.

Union Types — Telling the Compiler Exactly What’s Allowed

Why it matters first: Before union types, if you wanted a value that could be “one of a few specific types,” your options were limited. You could use object, but then absolutely anything could be assigned to it. You could use a shared interface or abstract base class, but any type that implements or inherits from it — even ones you never intended — would be accepted too. Neither approach lets the compiler tell you, with certainty, “this value can only ever be one of these three things.”

Union types close that gap. You define a fixed, known set of case types, and the compiler enforces it.

Note: The case types inside a union don’t need to be related through inheritance at all. If you want types that are related by inheritance to be restricted, that’s a job for closed hierarchies, covered next.

C#
public record class CreditCard(string LastFourDigits);
public record class BankTransfer(string AccountReference);
public record class DigitalWallet(string ProviderName);

public union PaymentMethod(CreditCard, BankTransfer, DigitalWallet);

PaymentMethod can only ever hold a CreditCard, a BankTransfer, or a DigitalWallet. Each of those types converts implicitly into PaymentMethod, so assigning one is straightforward:

C#
PaymentMethod payment = new DigitalWallet("PayPal");

string summary = payment switch
{
    CreditCard c => $"Card ending in {c.LastFourDigits}",
    BankTransfer b => $"Transfer ref {b.AccountReference}",
    DigitalWallet d => $"Wallet via {d.ProviderName}",
};

Notice there’s no default or discard arm in that switch expression. Because the compiler already knows every possible case type at compile time, it can confirm the switch is exhaustive on its own. If someone later adds a fourth payment type to the union, every switch expression like this one will now fail to compile until it’s updated — which means you catch the gap during a build, not in production.

Closed Hierarchies — Deciding Who Can Inherit From Your Types

Why it matters first: Open class hierarchies are flexible, but that flexibility comes at a cost. Anyone, in any assembly, can derive from a public base class unless you go out of your way to prevent it (usually with sealed, which then blocks all derivation). Closed hierarchies give you a middle ground: derivation is allowed, but only from a fixed, known list of types you control.

The new closed modifier makes a class implicitly abstract, and restricts direct derived types to the same assembly where the base class is declared.

C#
public closed record class DeliveryStatus;
public record class Pending : DeliveryStatus;
public record class InTransit(string Carrier) : DeliveryStatus;
public record class Delivered(DateTimeOffset DeliveredAt) : DeliveryStatus;
public record class Returned(string Reason) : DeliveryStatus;

No other assembly can add a fifth DeliveryStatus subtype. That guarantee matters a lot when you’re pattern-matching over the hierarchy elsewhere in your code — you know the compiler will flag it the moment the set of possibilities changes, instead of letting a missing case silently fall through at runtime.

Union types vs. closed hierarchies — when to use which:
AspectUnion TypesClosed Hierarchies
Relationship between case typesNo inheritance requiredTypes must derive from a shared base
Best forGrouping unrelated types under one logical valueModeling a fixed set of states or variants of the same concept
Declared asSeparate union declarationclosed modifier on the base type
Extending laterRequires changing the union declarationRestricted to the declaring assembly
Typical use caseA value that’s either a CreditCard, BankTransfer, or DigitalWalletOrder status, job state, workflow stages

Memory-Safety Redesign — A Preview Worth Watching

This is an experimental, preview-only feature in .NET 11 and C# 15. The design isn’t final, and Microsoft has said it may still change before it settles into its final form in a future release. Don’t build production logic around it yet — try it, but treat the current syntax as a moving target.

Why it matters first: Historically, unsafe in C# has mostly worked as a label — it tells the compiler “there might be pointer operations in here,” but it doesn’t distinguish between operations the compiler could actually verify as safe and ones where the developer is taking on responsibility the compiler can’t check. C# 15 starts moving unsafe toward becoming an actual contract instead of just a marker.

To try it, you need to opt in explicitly in your project file:

XML
<PropertyGroup>
  <LangVersion>preview</LangVersion>
  <Features>$(Features);updated-memory-safety-rules</Features>
</PropertyGroup>

Once enabled, a few operations that used to require an unsafe context no longer do — declaring a pointer type, taking an address with &, using fixed, converting stackalloc results, and calling sizeof on unmanaged types are all allowed in safe code now.

What still needs unsafe is anything that actually dereferences a pointer: pointer indirection (*p), member access through a pointer (p->Field), indexing through a pointer (p[i]), fixed-size buffer element access, and calling through a function pointer.

There’s also a breaking change worth flagging: if you mark a member’s signature with unsafe, that member can now only be called from an unsafe context — the caller has to explicitly acknowledge it. This is different from how unsafe on a member behaves today.

For most day-to-day application development this won’t come up often, but if you write low-level, performance-critical, or interop-heavy code, it’s worth trying out early and giving feedback while the design is still being shaped.

Collection Expression Arguments

Why it matters first: Collection expressions (the [...] syntax) made initializing collections shorter, but until now they couldn’t pass constructor or factory arguments — so if you needed to set an initial capacity or pass a custom comparer, you had to fall back to the older new(...) syntax. C# 15 fixes that with a with(...) element written at the start of the collection expression.

Before:

C#
List<string> tags = new(capacity: sourceTags.Length * 2);
tags.AddRange(sourceTags);

HashSet<string> uniqueCodes = new(StringComparer.OrdinalIgnoreCase) { "ABC", "abc" };

After (C# 15):

C#
List<string> tags = [with(capacity: sourceTags.Length * 2), .. sourceTags];

HashSet<string> uniqueCodes = [with(StringComparer.OrdinalIgnoreCase), "ABC", "abc"];

Both versions behave the same at runtime, but the second one stays inside the collection expression syntax you’re already using elsewhere in the codebase, instead of switching back and forth between two different initialization styles. This also lays the groundwork for the upcoming dictionary expressions feature, where specifying a comparer will be a common need.

Extension Indexers

Why it matters first: C# 14 introduced extension members — letting you add properties and operators to a type you don’t own, not just methods. C# 15 extends that further with extension indexers, so you can add indexer-style access (item[index]) to an existing type without modifying it.

This is most useful when a type has a natural “indexed access” concept that isn’t a plain collection — turning a helper method call into something that reads far more naturally.

Before — a helper method:

C#
public static class PlaylistExtensions
{
    public static Track TrackAtPosition(this Playlist playlist, int position)
        => playlist.Tracks.ElementAt(position);
}

Track next = myPlaylist.TrackAtPosition(3);

After (C# 15) — an extension indexer:

C#
public static class PlaylistExtensions
{
    extension(Playlist playlist)
    {
        public Track this[int position] => playlist.Tracks.ElementAt(position);
    }
}

Track next = myPlaylist[3];

A word of caution: extension indexers make the most sense on types that have a real, meaningful concept of “the item at this position” — like a Playlist, a Garage, or a Deck — rather than on plain IEnumerable<T> sequences, where a dedicated collection type or ElementAt is usually the better fit.

Labeled break and continue

Why it matters first: Breaking out of a nested loop has traditionally meant reaching for a boolean flag, a goto, or pulling the logic into a separate method just to return early. C# 15 lets you label a loop and target it directly.

Before — a flag to escape the outer loop:

C#
bool found = false;
foreach (var store in stores)
{
    foreach (var product in store.Products)
    {
        if (product.Sku == requestedSku && product.Stock > 0)
        {
            reservedProduct = product;
            found = true;
            break;
        }
    }
    if (found)
        break;
}

After (C# 15) — label the loop, break it directly:

C#
search: foreach (var store in stores)
{
    foreach (var product in store.Products)
    {
        if (product.Sku == requestedSku && product.Stock > 0)
        {
            reservedProduct = product;
            break search;
        }
    }
}

The intent is right there in the code — break search tells the reader exactly which loop is being exited, with no extra flag variable to track or clean up afterward. It’s worth noting this doesn’t do anything a well-placed goto couldn’t already achieve — the real benefit is readability: break search communicates purpose more clearly than a goto to a label further down the method.

Summary

C# 15 is still a preview release, but the direction is already clear: the language is pushing more of your intent into the type system, where the compiler can check it for you instead of leaving it to runtime bugs or code review. Union types and closed hierarchies both restrict what a value or a type hierarchy can be, so mistakes surface as compile errors instead of production incidents. The memory-safety redesign — still experimental — aims to turn unsafe into a real, checkable contract rather than a blanket warning label. And the smaller changes — collection expression arguments, extension indexers, and labeled break/continue — each remove a small piece of everyday ceremony from code you’re probably already writing.

None of these features are mandatory, and some (especially the memory-safety changes) are explicitly not final. But if you’re building anything on .NET 11, it’s worth downloading Preview 7 and trying a few of these in a side project before the final release lands in November. The earlier you get comfortable with them, the smoother the transition will be when C# 15 ships as stable.

Found this article useful? Share it with your network and spark a conversation.