C# 15 Preview: Union Types, Closed Hierarchies, and the Road to Safer Sums

Every year around this point in the .NET release cycle, the language features for the November release start to solidify. C# 15, riding along with the .NET 11 previews, has now grown a clear identity — and it’s the most consequential language update since nullable reference types. The headline theme is sum types done the C# way: union types, closed hierarchies, and exhaustiveness checking that actually works. Around that core sit smaller quality-of-life wins: collection expression arguments, extension indexers, labeled break and continue, and the first step of a multi-release effort to redefine what unsafe means in the language.

If you write C# for a living, the union and closed hierarchy features will change how you model domain state. This walkthrough covers what’s available in the current previews, what the compiler enforces, and the pitfalls you’ll hit while trying these features today.

Union types: sum types with a familiar face

C# has modeled “one of several states” for years with an abstract record plus a handful of derived records. It works, but nothing stops a downstream assembly from adding a new case, and nothing forces your switch to handle every case. C# 15 introduces union types, declared with the new union keyword:

public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);

public union Pet(Cat, Dog, Bird);

Each case type converts implicitly to the union, and the compiler verifies that switch expressions over the union cover every case:

Pet pet = new Dog("Rex");

string name = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
};

Remove the Bird arm and the compiler flags the switch as non-exhaustive — no more silent fall-through when a new case appears in the domain. This is the same ergonomics F# discriminated unions and Rust enums have offered for years, delivered with C#’s record-based syntax so case types remain ordinary types you can use independently of the union. The runtime side, UnionAttribute and the IUnion interface, ships starting with .NET 11 Preview 5, and the team has been explicit that some parts of the proposal specification aren’t implemented yet — expect the surface to fill out over remaining previews.

Closed hierarchies: exhaustiveness for class hierarchies

Unions are the new kid; closed hierarchies solve the same exhaustiveness problem for classic inheritance. Applying the closed modifier to a class restricts direct subclasses to the declaring assembly, which fixes the set of descendants at compile time:

// Assembly 1
public closed record class GateState;
public record class Closed : GateState;
public record class Open(float Percent) : GateState;

// Assembly 2
public record class Locked : GateState; // ERROR: 'GateState' is closed

Because the compiler can see every direct descendant, a switch covering them all is exhaustive without a catch-all arm:

string Describe(GateState state) => state switch
{
    Closed => "closed",
    Open(var percent) => $"{percent}% open",
    // No warning: every direct descendant is handled
};

The feature specification holds a few details worth knowing before you adopt it. A closed class is implicitly abstract — it can’t be sealed, static, or explicitly abstract. The restriction is deliberately not transitive: a subclass that isn’t itself closed can be extended from other assemblies, so exhaustiveness checking doesn’t flow down the hierarchy unless you mark intermediate types closed too. And if you’re experimenting with the current preview, there’s a speed bump: the runtime doesn’t yet ship ClosedAttribute, so every project using closed must declare the attribute in the System.Runtime.CompilerServices namespace itself. It’s a three-line workaround, but one that will bite anyone who tries the feature cold.

The supporting cast

Three smaller features round out the release. Collection expression arguments let a collection expression pass arguments to the underlying constructor using a with(...) element — handy for pre-sizing or comparers:

string[] values = ["one", "two", "three"];

// Pre-size the list within the collection expression itself
List<string> names = [with(capacity: values.Length * 2), .. values];

// Pass a comparer to the HashSet constructor
HashSet<string> set = [with(StringComparer.OrdinalIgnoreCase), "Hello", "HELLO", "hello"];
// set contains one element

Extension indexers extend the C# 14 extension-everything work to indexers. An extension block with a named receiver parameter can now declare an indexer, so you can index into types you don’t own:

public static class SequenceIndexer
{
    extension(IEnumerable<int> sequence)
    {
        public int this[int index] => sequence.ElementAt(index);
    }
}

IEnumerable<int> numbers = Enumerable.Range(1, 10);
int third = numbers[2];

Labeled break and continue finally give C# the structured escape hatch that Java has had since the nineties. A label names an enclosing loop or switch, and the jump statement targets it directly — replacing the Boolean-flag-pushed-to-the-outer-loop idiom. A new IDE0410 analyzer even flags the old patterns and suggests the rewrite. If you want the full feature list as it evolves, the Roslyn language feature status page tracks what’s merged for each preview.

The quiet one: redefining unsafe

The sleeper feature of C# 15 is the start of a multi-release effort to redefine memory safety. Today, unsafe is attached to pointer types; in the target model, it attaches to the operations that actually touch unmanaged memory. Under the preview language version, declaring a pointer, taking an address with &, the fixed statement, converting stackalloc to a pointer, and sizeof no longer require an unsafe context. Dereferencing — *p, p->member, p[i], function pointer invocation — still does:

int number = 42;
int* pointer = &number;

int[] numbers = [10, 20, 30];
fixed (int* first = numbers)
{
    // Dereferencing 'first' still requires an unsafe context
}

The motivation is auditability: most memory-safety vulnerabilities live in the access operations, and making those the things reviewers grep for narrows the blast radius. Later previews add a “requires-unsafe” member model, an assembly-level opt-in recorded via MemorySafetyRulesAttribute, and a safe contextual keyword — details are in the unsafe code reference. Unless you maintain interop-heavy or performance-critical code, this is one to watch rather than adopt.

What to do with a preview

C# 15 features are available now in Visual Studio 2026 insiders and the .NET 11 preview SDK, with GA expected alongside .NET 11 in November. The pragmatic play: model your next state machine with a union or a closed hierarchy in a spike project and see how exhaustiveness checking changes your tests. The pattern-match-everywhere style that unions encourage tends to surface missing cases at compile time — cases that previously announced themselves in production at 3 a.m.

If you maintain a public library, start thinking now about which of your abstract base classes are conceptually closed. Marking them closed at GA gives your consumers exhaustiveness checking for free — but it’s also a breaking change for anyone who derives from them, so it belongs in a major version. The What’s new in .NET 11 overview is the right place to watch the rest of the platform catch up to the language.

Leave a Reply

Your email address will not be published. Required fields are marked *