What's New in C# 15 - Features & Code Examples

Explore C# 15 features in .NET 11 with code examples: union types, closed hierarchies, collection with(), extension indexers, and labeled break.

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 and .NET 11 overview banner with official C# logo
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
Pro Tip: C# version numbers follow the .NET release they ship with. C# 15 = .NET 11. C# 14 = .NET 10. You set the version with <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.
};
Under the Hood: The compiler generates an internal struct implementing 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}"
};
Domain Modeling Tip: This pairs well with Domain-Driven Design (DDD). Model your aggregate states or value objects as closed hierarchies to ensure all direct subclasses are known at compile time within your core domain assembly.

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.

Source:
Microsoft Learn: What's new in C# 15 | .NET Blog

About the author

MOHAMMED KADI
Software Engineer. Passionate about IT certifications, automation, and building scalable tech solutions.

Post a Comment

Welcome to Iwalen.com! If you have any questions or need assistance with any of our resources, feel free to ask. Please keep the discussion professional and avoid posting external links. All comments are moderated to ensure a high-quality community experience.