C# 15 lands alongside .NET 11 in November 2026, and it is one of those releases where the language actually feels different after you start using it. Native union types, closed hierarchies, smarter collection expressions, extension indexers, and labeled loops: the C# team shipped features developers have been asking about for years.
![]() |
| C# 15 new features overview in .NET 11: union types, closed hierarchies, and extension indexers. |
This post walks through the major C# 15 features available in the .NET 11 preview and release candidate builds with realistic before-and-after code so you can see exactly what changes and why it matters. No fluff, no speculation: just grounded syntax and practical examples you can try right now with the .NET 11 SDK.
C# 14 vs C# 15 at a Glance
Before we look at each feature in detail, here is a quick comparison so you know what is brand-new in C# 15 and what is an evolution of C# 14.
| Area | C# 14 (.NET 10) | C# 15 (.NET 11) |
|---|---|---|
| Sum / Union Types | Not available natively (relies on OneOf NuGet or custom types) | Native union keyword with exhaustive matching |
| Closed Hierarchies | Only sealed (no inheritance at all) |
closed modifier: controlled, finite direct inheritance |
| Collection Expressions | Basic [...] syntax (C# 12+) |
with(...) arguments for capacity, comparers, etc. |
| Extension Members | Extension methods, properties, static members | Extension indexers added |
| Loop Control Flow | Plain break / continue |
Labeled break / continue for nested loops |
| Unsafe / Memory Safety | Coarse-grained unsafe { } blocks |
Granular model: expression-scoped unsafe(...), safe modifier |
<LangVersion> in your .csproj, but the default is always the version matching your target framework.
Discriminated Unions: The union Keyword
This is the headline feature of C# 15. For years, C# developers improvised with abstract base classes, marker interfaces, or third-party libraries. While libraries like OneOf offered functional-style matching through methods, they could not offer first-class integration with C# pattern matching and switch expressions. C# 15 finally brings native language support.
What Is a Union Type?
A union (also called a "sum type" or "tagged union") is a type that can hold exactly one of a fixed set of case types: and nothing else. The compiler knows every possible shape at compile time, which means it can verify whether you have handled all cases in a switch expression.
Before C# 15: The Old Workaround
Imagine you are building an API that returns different result states. Here is the typical C# 14 approach:
// C# 14: The manual abstract base approach
public abstract record ApiResult;
public record Success(string Data) : ApiResult;
public record ValidationError(string[] Errors) : ApiResult;
public record NotFound(string ResourceId) : ApiResult;
public record ServerError(Exception Ex) : ApiResult;
// Consumer code
public string HandleResult(ApiResult result)
{
return result switch
{
Success s => $"OK: {s.Data}",
ValidationError v => $"Invalid: {string.Join(", ", v.Errors)}",
NotFound n => $"Missing: {n.ResourceId}",
ServerError e => $"Boom: {e.Ex.Message}",
_ => throw new InvalidOperationException("Unknown result type")
// ^ You NEED this default arm because the compiler cannot guarantee
// that no one else subclasses ApiResult in another assembly.
};
}
The pain points are real:
- Nothing stops another developer from adding
public record Timeout : ApiResult;in a different project: your switch silently hits the discard arm at runtime. - The
_ =>arm masks bugs. You do not get a compile-time warning when a new case is added. - The intent ("these four states are the only possibilities") is not expressed in the type system.
The C# 15 Way: Native union
// C# 15: Native discriminated union
public record Success(string Data);
public record ValidationError(string[] Errors);
public record NotFound(string ResourceId);
public record ServerError(Exception Ex);
// The compiler now knows these four types are the ONLY members of ApiResult.
public union ApiResult(Success, ValidationError, NotFound, ServerError);
// Consumer code
public string HandleResult(ApiResult result)
{
// No default arm needed: the compiler verifies exhaustiveness!
return result switch
{
Success s => $"OK: {s.Data}",
ValidationError v => $"Invalid: {string.Join(", ", v.Errors)}",
NotFound n => $"Missing: {n.ResourceId}",
ServerError e => $"Boom: {e.Ex.Message}"
};
}
// Implicit conversion: assign case instances directly
ApiResult ok = new Success("user-profile-json");
ApiResult fail = new NotFound("user-42");
Compiler Warning CS8509:
If you later add a fifth case to the union (such as Timeout) and forget to update a switch expression, the compiler issues a CS8509 exhaustiveness warning. To make this break the build immediately, add <WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors> to your .csproj.
Another Example: Geometry Shapes
// Geometry shapes union
public record Circle(double Radius);
public record Rectangle(double Width, double Height);
public record Point;
public union Shape(Circle, Rectangle, Point);
public double CalculateArea(Shape shape) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
Point => 0.0
// Exhaustive: no discard arm required.
};
IUnion decorated with [Union], storing the current instance in an object? Value field. Note that value types (primitives or structs) will be boxed into object? in simple union cases unless accessed through custom non-boxing patterns.
Closed Hierarchies: The closed Modifier
The closed modifier applies to classes to restrict direct inheritance to types defined within the same assembly. A closed class is abstract by default and cannot be combined with abstract, sealed, or static.
Why sealed Isn't Enough
sealed prevents all inheritance. Sometimes you want a small, controlled inheritance tree within your own library without allowing external consumers to subclass it. That is what closed provides.
Before C# 15
// C# 14: No way to restrict inheritance to "just these subtypes"
public abstract class PaymentMethod { }
public class CreditCard : PaymentMethod
{
public string Last4 { get; init; } = "";
}
public class BankTransfer : PaymentMethod
{
public string IBAN { get; init; } = "";
}
public class Crypto : PaymentMethod
{
public string WalletAddress { get; init; } = "";
}
// Any consumer in another assembly can create:
// public class UnknownPayment : PaymentMethod { }
// That leaves switch statements open:
string Describe(PaymentMethod pm) => pm switch
{
CreditCard cc => $"Card ending in {cc.Last4}",
BankTransfer b => $"Bank: {b.IBAN}",
Crypto c => $"Wallet: {c.WalletAddress}",
_ => "Unknown" // Always needed because hierarchy is open
};
The C# 15 Way: closed
// C# 15: Closed class hierarchy
public closed class PaymentMethod { }
public class CreditCard : PaymentMethod
{
public string Last4 { get; init; } = "";
}
public class BankTransfer : PaymentMethod
{
public string IBAN { get; init; } = "";
}
public class Crypto : PaymentMethod
{
public string WalletAddress { get; init; } = "";
}
// External assemblies cannot directly inherit from PaymentMethod.
// The compiler knows all direct descendants within this assembly:
string Describe(PaymentMethod pm) => pm switch
{
CreditCard cc => $"Card ending in {cc.Last4}",
BankTransfer b => $"Bank: {b.IBAN}",
Crypto c => $"Wallet: {c.WalletAddress}"
};
sealed vs closed: Quick Reference
| Modifier | Inheritance Allowed? | Where? | Exhaustive Matching? |
|---|---|---|---|
sealed |
No | N/A | N/A (single type) |
abstract |
Yes | Any assembly | No (hierarchy is open) |
closed |
Yes | Same assembly only (direct descendants) | Yes (compiler knows all direct descendants) |
Configurable Collection Expressions: with(...) Syntax
Collection expressions ([...]) introduced in C# 12 made initializing collections clean. However, there was a limitation: you could not pass constructor arguments such as initial capacity or custom comparers. C# 15 introduces the with(...) clause at the beginning of the collection expression.
Before C# 15
// C# 14: Initial capacity required falling back to standard constructors
// Option A: Traditional constructor syntax
var names = new List<string>(capacity: 500)
{
"Alice", "Bob", "Charlie"
};
// Option B: Collection expression (allocates with default capacity)
List<string> names2 = ["Alice", "Bob", "Charlie"];
// Custom comparer required standard constructor syntax:
var lookup = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Hello", "HELLO"
};
The C# 15 Way: with(...)
// C# 15: Pass constructor arguments inside the collection expression
// Initialize with a preset capacity:
List<string> names = [with(capacity: 500), "Alice", "Bob", "Charlie"];
// Pass a custom comparer to a set:
HashSet<string> lookup = [with(StringComparer.OrdinalIgnoreCase), "Hello", "HELLO"];
// lookup.Count == 1 (case-insensitive deduplication)
Rules for with(...):
The with(...) element must always be the first item in the collection expression. It is supported on collection types that expose matching constructors or factory methods (such as List<T> and HashSet<T>). It does not apply to fixed arrays or Spans.
Extension Indexers
C# 14 introduced extension blocks to declare extension methods, properties, and static members together. C# 15 expands this by allowing extension indexers (this[...]) on existing types.
This is useful when writing adapter layers or working with custom data buffers and third-party types where indexer access provides a natural syntax.
Before C# 15
// C# 14: Requires separate extension methods
public static class SensorReadingsExtensions
{
public static double GetReading(this SensorDataBuffer buffer, int index)
=> buffer.GetRawValue(index);
public static void SetReading(this SensorDataBuffer buffer, int index, double value)
=> buffer.SetRawValue(index, value);
}
// Usage:
double temp = buffer.GetReading(3);
buffer.SetReading(3, 98.6);
The C# 15 Way: Extension Indexer
// C# 15: Add an indexer to SensorDataBuffer via extension block
public static class SensorReadingsExtensions
{
extension(SensorDataBuffer buffer)
{
public double this[int index]
{
get => buffer.GetRawValue(index);
set => buffer.SetRawValue(index, value);
}
}
}
// Usage: natural indexer syntax
double temp = buffer[3];
buffer[3] = 98.6;
Note:
Extension indexers require a named target parameter inside the extension declaration (e.g., extension(SensorDataBuffer buffer)). They support get, set, and ref returns.
Labeled break and continue
When working with nested loops or nested switch blocks, breaking out of an outer loop previously required boolean flags or goto statements. C# 15 adds labeled loop control flow.
Before C# 15
// C# 14: Nested loops required state flags to break early
int[,] matrix = GetMatrix();
bool found = false;
for (int row = 0; row < matrix.GetLength(0); row++)
{
for (int col = 0; col < matrix.GetLength(1); col++)
{
if (matrix[row, col] == targetValue)
{
Console.WriteLine($"Found at [{row},{col}]");
found = true;
break; // Breaks only the inner loop
}
}
if (found) break; // Extra check to escape outer loop
}
The C# 15 Way: Labeled Loops
// C# 15: Direct break to outer loop
int[,] matrix = GetMatrix();
outerLoop:
for (int row = 0; row < matrix.GetLength(0); row++)
{
for (int col = 0; col < matrix.GetLength(1); col++)
{
if (matrix[row, col] == targetValue)
{
Console.WriteLine($"Found at [{row},{col}]");
break outerLoop; // Exits both loops immediately
}
}
}
Labeled continue works similarly to skip directly to the next iteration of an outer loop. The Roslyn compiler also provides code fixes (IDE0410) suggesting labeled breaks where flag-based patterns are detected.
Memory Safety and Unsafe Code Refinements
C# 15 introduces granular memory safety rules in preview. In previous versions, taking pointers and calling unmanaged members required placing entire code blocks inside unsafe { }.
Safe Pointer Declarations
// Unmanaged struct
struct Vec3 { public float X, Y, Z; }
Vec3 v = new();
// In C# 15 preview, sizeof on unmanaged types is safe:
int size = sizeof(Vec3);
// Declaring pointer variables and taking addresses no longer requires an unsafe block:
Vec3* ptr = &v;
// Dereferencing (actual memory access) still requires explicit unsafe context:
Vec3 read = unsafe(*ptr);
Expression-Scoped unsafe(...)
Instead of wrapping whole method bodies in unsafe { }, you can scope unsafe expressions precisely where needed (such as field initializers, constructor initializers, or when exception filters):
public class NativeBuffer
{
// Scoped unsafe expression inside a field initializer:
private readonly unsafe int* _bufferPtr = unsafe(GetRawBuffer());
private static unsafe int* GetRawBuffer() => (int*)IntPtr.Zero;
}
The safe Modifier on Interop Declarations
using System.Runtime.InteropServices;
public static partial class NativeInterop
{
// Explicitly annotate that this extern call is verified as memory-safe:
[LibraryImport("libc")]
internal static safe partial uint getuid();
}
Opt-In Preview:
The updated memory safety rules are an opt-in preview feature. To enable them, your project file must set <AllowUnsafeBlocks>true</AllowUnsafeBlocks> alongside <Features>$(Features);updated-memory-safety-rules</Features>.
How to Try C# 15 Right Now
Step 1: Update Your Project File
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Optional: enable updated memory safety preview -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Features>$(Features);updated-memory-safety-rules</Features>
</PropertyGroup>
</Project>
Step 2: Tooling Requirements
| Tool | Recommended Version |
|---|---|
| .NET SDK | 11.0.100 Preview / Release Candidate |
| Visual Studio | Visual Studio 2026 (v18.0 Preview / Insiders) |
| VS Code | C# Dev Kit with .NET 11 SDK installed |
| JetBrains Rider | Latest version with .NET 11 preview support |
Step 3: Verify It Works
# Check your SDK version (will display 11.0.100-preview.x or -rc.x)
dotnet --version
# Create a test project
dotnet new console -n CSharp15Demo --framework net11.0
# Build and run
cd CSharp15Demo
dotnet run
C# 15 New Features: Full Summary
| Feature | One-Liner | Best For |
|---|---|---|
| Union Types | Native sum types with compiler-checked exhaustive matching | Result types, state machines, domain modeling |
| Closed Hierarchies | Restrict direct inheritance to the declaring assembly | Domain models, controlled polymorphic trees |
Collection with(...) |
Pass constructor arguments to collection expressions | Setting initial capacity, custom equality comparers |
| Extension Indexers | Add indexer access to existing types via extension blocks | SDK wrappers, custom data buffers |
Labeled break/continue |
Break or continue directly to named enclosing loops | Nested loops, matrix algorithms, search operations |
| Memory Safety Refinements | Expression-scoped unsafe(...) and safe pointer declarations |
Interoperability, systems programming |
Frequently Asked Questions
When is C# 15 released?
C# 15 ships alongside .NET 11, with General Availability (GA) scheduled for November 2026. Preview and Release Candidate builds are available through the .NET 11 SDK by configuring <LangVersion>preview</LangVersion> in your project.
Does C# 15 support true discriminated unions?
Yes. C# 15 introduces native discriminated unions via the union keyword. You define a union with specific case types, and the compiler tracks exhaustiveness in switch expressions, issuing CS8509 warnings if any cases are unhandled.
Is .NET 11 an LTS (Long-Term Support) release?
No. .NET 11 is a Standard Term Support (STS) release supported for 24 months. Microsoft alternates between LTS releases (even numbers: .NET 8, .NET 10) and STS releases (odd numbers: .NET 9, .NET 11).
Can I use C# 15 in production immediately?
Stable language features can be adopted once .NET 11 reaches General Availability (GA) in November 2026. Experimental flags, such as the updated memory safety rules feature, remain in preview status and are intended for testing and feedback.
What is the difference between C# 15 union types and the OneOf NuGet library?
The OneOf library enforces handling via fluent methods like .Match(). Native C# 15 unions integrate directly into the language, supporting standard switch expressions, pattern matching, and nullable reference types without external dependencies.
What is the difference between closed and sealed in C# 15?
A sealed class cannot be inherited by any type. A closed class allows direct inheritance only within the same assembly, letting you define a finite set of subclasses that the compiler can verify exhaustively.
Wrapping Up
C# 15 focuses on practical improvements. Union types and closed hierarchies make domain modeling safer by catching missing cases at compile time. Collection expression arguments and extension indexers simplify everyday code patterns. Labeled breaks eliminate awkward loop control flags, and granular unsafe refinements begin a modernization of low-level C#.
To get started, install the .NET 11 SDK, enable <LangVersion>preview</LangVersion>, and explore union types in your own test projects.
