.NET 11 is out: a tour of the new C# 15 features
.NET 11 shipped on November 10, 2026. It is a Standard Term Support (STS) release: Microsoft will service it until November 14, 2028 — the very same day as the LTS release .NET 10. C# 15 ships alongside it and is the default for projects targeting net11.0, so there is nothing to add to the .csproj.
This post walks through every new language feature: union types, closed class hierarchies, collection expression arguments, extension indexers, labeled break and continue, static interface members on older runtimes, and a preview of the new memory safety rules. Every example was built and run on SDK 11.0.100 and the output is real. Compiler errors are shown with their codes, exactly as dotnet build prints them.
Setup
Install the SDK from dotnet.microsoft.com and check it:
dotnet --list-sdks
10.0.403 [C:\Program Files\dotnet\sdk]
11.0.100 [C:\Program Files\dotnet\sdk]
Create a console project to try the examples in:
dotnet new console -n Demo -f net11.0
cd Demo
All the code below goes into Program.cs (top-level statements first, type declarations after them); run it with dotnet run.
Union types
The headline feature of C# 15. A union type is a value that at any moment holds exactly one of a fixed list of types. F#, Rust, TypeScript and Swift have had this for years; in C# you had to build a class hierarchy, fall back to object, or write your own OneOf<T1, T2>.
Declaration
A union is declared with the union keyword and a list of case types in parentheses:
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
The cases are ordinary types declared separately. They don't have to be records and don't have to share a base class: a union can just as well combine int and string:
public union IntOrString(int, string);
Each case type converts implicitly to the union, so assignment looks natural:
Pet pet = new Dog("Rex");
IntOrString a = 42;
IntOrString b = "forty-two";
Pattern matching
A union is taken apart with switch. A type pattern (Dog d) tests not the union itself but what it holds:
static string Describe(Pet? pet) => pet switch
{
Dog d => $"dog {d.Name}",
Cat c => $"cat {c.Name}",
Bird b => $"bird {b.Name}",
null => "no pet",
};
Console.WriteLine(Describe(pet));
Console.WriteLine(Describe(null));
Console.WriteLine(Describe(default(Pet)));
dog Rex
no pet
no pet
Note there is no _ arm, and the compiler doesn't complain. It knows the full list of cases and treats the switch as exhaustive once each one is handled. Remove the Bird arm:
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bird' is not covered.
This is the main reason to use unions: add a new case and the compiler points at every switch that doesn't handle it. A _ arm turns this check off, so it's best left out.
The null arm is needed because a union is a struct, and its default value (default(Pet)) holds nothing. The null pattern catches both a null Pet? and an empty union. Without that arm, a switch over an empty union throws SwitchExpressionException.
A type pattern that can't match any case is an error:
static bool WrongType(Pet pet) => pet is string;
error CS8121: An expression of type 'Pet' cannot be handled by a pattern of type 'string'.
Property patterns work too — when the pattern names a type, it also applies to the contents:
Console.WriteLine(pet is Dog { Name: "Rex" });
True
How a union works inside
The compiler lowers a union declaration to a plain struct marked with [Union] that implements the IUnion interface from System.Runtime.CompilerServices:
public interface IUnion
{
object? Value { get; }
}
Each case gets its own public constructor, and the contents live in a single field of type object?. Two things follow from this.
First, value-type cases are boxed. Let's check:
IntOrString v = 5;
Console.WriteLine(typeof(IntOrString).IsValueType);
Console.WriteLine(v.Value?.GetType());
True
System.Int32
The union itself is a value type, but the int inside lives on the heap. Keep that in mind for hot code with primitives; below you'll see how to write your own union without boxing.
Second, a union is not a record. It gets no value equality, no with, no deconstruction.
And one trap. Since type patterns test the contents, a test for the IUnion interface itself goes inside too:
Console.WriteLine(new Pet(new Cat("Tom")) is IUnion u ? u.Value : "no");
no
Cat doesn't implement IUnion, so the answer is no, even though Pet does.
Members and generic unions
A union body can declare methods and properties. Inside a member, this is the union, and switch works on it the same way:
public record class Meters(double Value);
public record class Feet(double Value);
public union Length(Meters, Feet)
{
public double TotalMeters => this switch
{
Meters m => m.Value,
Feet f => f.Value * 0.3048,
_ => throw new InvalidOperationException("The Length has no value."),
};
public Length Add(Length other) => new Meters(TotalMeters + other.TotalMeters);
}
Length len = new Meters(1);
len = len.Add(new Feet(10));
Console.WriteLine(len.TotalMeters);
Console.WriteLine(len.Value);
4.048
Meters { Value = 4.048 }
Instance fields and auto-properties aren't allowed in a union: all of its state is Value.
A union can be generic. The classic example is the result of an operation that holds either a value or an error:
public record class ParseError(string Message);
public union Result<T>(T, ParseError);
static Result<int> Parse(string s) =>
int.TryParse(s, out var n) ? n : new ParseError($"'{s}' is not a number");
The conditional operator works here thanks to the implicit conversions: both int and ParseError convert to Result<int>.
Console.WriteLine(Parse("12") switch
{
int n when n > 10 => "greater than ten",
int n => $"number {n}",
ParseError e => $"error: {e.Message}",
null => "empty",
});
Console.WriteLine(Parse("abc").Value);
greater than ten
ParseError { Message = 'abc' is not a number }
Here the compiler itself asks for the null arm: T may turn out to be a nullable reference type, so Value is "maybe null". Without it:
warning CS8655: The switch expression does not handle some null inputs (it is not exhaustive). For example, the pattern 'null' is not covered.
A custom union type
The union keyword is shorthand. The compiler treats as a union any type marked with [Union] that has public single-parameter constructors (each one defines a case) and a Value property of type object?:
using System.Runtime.CompilerServices;
[Union]
public struct Shape : IUnion
{
private readonly object? _value;
public Shape(Circle value) { _value = value; }
public Shape(Rectangle value) { _value = value; }
public object? Value => _value;
}
public record class Circle(double Radius);
public record class Rectangle(double Width, double Height);
Shape shape = new Shape(new Circle(5.0));
Console.WriteLine(shape switch
{
Circle c => Math.Round(Math.PI * c.Radius * c.Radius, 2),
Rectangle r => r.Width * r.Height,
});
78.54
Exhaustiveness checks and patterns work exactly as for a type declared with union. A custom type is what you want when the contents should be stored without boxing: add a HasValue method and a TryGetValue(out T) for each case, and pattern matching will call those instead of Value. A union can also be a class rather than a struct.
Unions and JSON
In .NET 11, System.Text.Json serializes unions: the JSON contains the contents, with no wrapper. The pets get different properties here, so this example is best built in a separate project.
using System.Text.Json;
public record Cat(string Name, int Lives);
public record Dog(string Name, string Breed);
public union Pet(Cat, Dog);
public union IntOrString(int, string);
Console.WriteLine(JsonSerializer.Serialize<Pet>(new Dog("Rex", "beagle")));
Console.WriteLine(JsonSerializer.Deserialize<IntOrString>("5").Value);
Console.WriteLine(JsonSerializer.Deserialize<IntOrString>("\"five\"").Value);
{"Name":"Rex","Breed":"beagle"}
5
five
The cases of IntOrString differ by the kind of JSON value (number and string), so the deserializer picks the case on its own. But Cat and Dog are both objects, and deserializing Pet fails:
JSON value type 'Object' is ambiguous for union type 'Pet' because multiple case types can use this value type. Specify a custom type classifier to support deserialization.
Picking the case by its set of properties is turned on with an attribute:
using System.Text.Json.Serialization;
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Pet(Cat, Dog);
Console.WriteLine(JsonSerializer.Deserialize<Pet>("""{"Name":"Tom","Lives":9}""").Value);
Console.WriteLine(JsonSerializer.Deserialize<Pet>("""{"Name":"Rex","Breed":"beagle"}""").Value);
Cat { Name = Tom, Lives = 9 }
Dog { Name = Rex, Breed = beagle }
The classifier validates the cases up front. If Dog had all the properties of Cat plus some more (say, Cat(string Name) and Dog(string Name, int Age)), it would refuse to work on first use: Cat could never be picked unambiguously. The same machinery lets ASP.NET Core 11 accept a union in the request body of Minimal APIs and MVC, and OpenAPI describes such a parameter with anyOf.
Closed hierarchies: the closed modifier
A union fits when the cases are different, unrelated types. When the cases are subclasses of a common base class, C# 15 has another tool: the closed modifier.
public closed record class GateState;
public record class Closed : GateState;
public record class Open(float Percent) : GateState;
A closed class is implicitly abstract, and it can only be derived from directly within the same assembly. So the compiler knows the full list of direct subclasses and checks switch for exhaustiveness:
static string DescribeGate(GateState state) => state switch
{
Closed => "closed",
Open(var percent) => $"{percent}% open",
};
Console.WriteLine(DescribeGate(new Open(40)));
Console.WriteLine(DescribeGate(new Closed()));
40% open
closed
No _ and no throw at the end. A forgotten case gets the same warning as with a union:
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Open' is not covered.
You can't write abstract (it's already implied), and sealed and static contradict the whole idea:
error CS9384: 'Bad2': a closed type cannot be marked abstract because it is always implicitly abstract.
error CS9381: 'Bad': a closed type cannot be sealed or static
Across an assembly boundary
Let's test the restriction on derivation. Create a library:
dotnet new classlib -n Lib -f net11.0
dotnet new console -n Ext -f net11.0
dotnet add Ext reference Lib
Lib holds a hierarchy with a closed intermediate level:
namespace Lib;
public closed record class Shape;
public record class Circle(double R) : Shape;
public closed record class Polygon : Shape;
public record class Square(double A) : Polygon;
In Ext we try to add our own subclasses:
using Lib;
record Triangle(double A) : Shape;
record Pentagon : Polygon;
error CS9382: 'Triangle': cannot use a closed type 'Shape' from another assembly as a base type.
error CS9382: 'Pentagon': cannot use a closed type 'Polygon' from another assembly as a base type.
Closedness isn't inherited: it only covers direct subclasses. If Polygon weren't closed, anyone could derive from it in any assembly, and a switch over Shape with Circle and Polygon arms would still be exhaustive. But a switch with a Square arm instead of Polygon would then get CS8509: the compiler can't know all subclasses of Polygon. Here Polygon is closed, so even a switch that breaks it down to Square counts as exhaustive:
static double Area(Shape s) => s switch
{
Circle c => Math.PI * c.R * c.R,
Square q => q.A * q.A,
};
It builds without warnings. How closed differs from a union: the cases must derive from one class and live in one assembly, but there is no boxing, no wrapper and no Value, and shared members can live in the base class.
Collection expression arguments: with(...)
Collection expressions ([1, 2, 3]) arrived in C# 12, but there was no way to pass a capacity or a comparer to the collection's constructor — you had to go back to new. In C# 15 the first element can be with(...), and its arguments go to the constructor:
string[] values = ["one", "two", "three"];
List<string> names = [with(capacity: values.Length * 2), .. values];
Console.WriteLine($"{names.Count} / {names.Capacity}");
HashSet<string> set = [with(StringComparer.OrdinalIgnoreCase), "Hello", "HELLO", "hello"];
Console.WriteLine(set.Count);
SortedSet<int> sorted = [with(Comparer<int>.Create((x, y) => y.CompareTo(x))), 3, 1, 2];
Console.WriteLine(string.Join(",", sorted));
3 / 6
1
3,2,1
The constructor is chosen by ordinary overload resolution, and named arguments work. For types with [CollectionBuilder], the arguments go to the factory method before the ReadOnlySpan<T>. When the target is an interface, the compiler picks the implementation itself; for IList<T> and ICollection<T> that is List<T>, and the capacity reaches it:
IList<int> list = [with(capacity: 100), 1, 2];
Console.WriteLine($"{list.GetType().Name} {((List<int>)list).Capacity}");
List`1 100
Restrictions. with must be the first element:
List<int> l = [1, with(capacity: 10)];
error CS9354: 'with(...)' element must be the first element
Arrays and spans have no constructor, so no arguments either:
int[] arr = [with(10), 1];
error CS9355: 'with(...)' elements are not supported for type 'int[]'
And dictionaries still can't be created with collection expressions at all; with doesn't help here:
IDictionary<string, int> dict = [with(StringComparer.OrdinalIgnoreCase)];
error CS9174: Cannot initialize type 'IDictionary<string, int>' with a collection expression because the type is not constructible.
The ["key": value] syntax for dictionaries is a separate proposal that didn't make it into C# 15.
Extension indexers
C# 14 introduced extension blocks, where you can declare extension properties and static extension members. C# 15 adds indexers to them:
public static class SequenceIndexer
{
extension(IEnumerable<int> sequence)
{
public int this[int index] => sequence.ElementAt(index);
}
}
IEnumerable<int> numbers = Enumerable.Range(1, 10);
Console.WriteLine(numbers[2]);
3
The more interesting part is that an extension indexer takes part in from-end indices (^1), ranges (1..3) and list patterns. For that the type needs Count (or Length), an int indexer and Slice — and all three can be extensions:
public class Readings(int[] values)
{
public int[] Values { get; } = values;
}
public static class ReadingsExtensions
{
extension(Readings r)
{
public int Count => r.Values.Length;
public int this[int index] => r.Values[index];
public Readings Slice(int start, int length) => new(r.Values.AsSpan(start, length).ToArray());
}
}
Readings readings = new([10, 20, 30, 40]);
Console.WriteLine(readings[^1]);
Console.WriteLine(readings[1..3][0]);
Console.WriteLine(readings is [10, .., 40]);
40
20
True
The Readings class itself has no indexer, no Count and no Slice — it all comes from extensions. A type's own indexer always wins over an extension, and extension indexers never apply to arrays or string.
Labeled break and continue
There used to be three ways to leave a nested loop early, all of them bad: a flag, goto, or moving the loops into a separate method just to return. In C# 15, break and continue take a label — the name of the loop they refer to:
int[][] grid = [[1, 2, -1, 4], [5, 6, 7, 8], [9, 0, 3, 3]];
outer: for (int row = 0; row < grid.Length; row++)
{
for (int col = 0; col < grid[row].Length; col++)
{
if (grid[row][col] < 0)
{
Console.WriteLine($"row {row}: skipped");
continue outer;
}
if (grid[row][col] == 0)
{
Console.WriteLine($"zero at [{row},{col}]");
break outer;
}
}
Console.WriteLine($"row {row}: checked");
}
row 0: skipped
row 1: checked
zero at [2,1]
continue outer moves on to the next iteration of the outer loop, skipping the "checked" line, and break outer ends both loops at once.
It's especially handy inside switch, where a plain break only ends the case:
lines: foreach (var line in new[] { "a", "stop", "b" })
{
switch (line)
{
case "stop":
break lines;
default:
Console.WriteLine(line);
break;
}
}
a
The label uses the same syntax as for goto, but break and continue only accept the label of a loop or switch that encloses them:
x: Console.WriteLine();
for (;;) { break x; }
error CS9393: No enclosing loop or switch statement with the label 'x' out of which to break
In Visual Studio, the IDE0410 style rule finds the old flag and goto constructs and offers to replace them with a label.
Static interface members on older runtimes
A small change, but a useful one for library authors. Non-virtual static interface members with a body:
Console.WriteLine(IMeasurement.ToMeters(250));
public interface IMeasurement
{
public static double ToMeters(double centimeters) => centimeters / 100;
}
2.5
used to require a runtime that supports default interface implementations — so they didn't build for .NET Framework or netstandard2.0. The runtime does nothing special for such a member, it's an ordinary static method, and C# 15 lifted the restriction. Let's check with a netstandard2.0 library, where the default is C# 7.3, so we set the language version explicitly:
dotnet new classlib -n Old -f netstandard2.0
public interface IMeasurement
{
public static double ToMeters(double centimeters) => centimeters / 100;
}
public interface IDefault
{
void M() { }
}
dotnet build Old -p:LangVersion=15
error CS8701: Target runtime doesn't support default interface implementation.
There is a single error, on IDefault.M: a default implementation of an instance member, like static abstract and static virtual, still needs a modern runtime. The static ToMeters builds.
Memory safety (preview)
The last feature is a preview. It's the first step of a large redesign of unsafe spread over several releases; Microsoft warns that the syntax and rules may still change for C# 16. It's turned on in the .csproj with three properties:
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<LangVersion>preview</LangVersion>
<Features>$(Features);updated-memory-safety-rules</Features>
</PropertyGroup>
The idea: working with a pointer is not dangerous in itself, reading and writing memory through it is. So declaring a pointer, taking an address with &, writing fixed or sizeof no longer needs unsafe, while dereferencing with *p, p->x and p[i] still requires an unsafe block. On top of that, unsafe on a method changes meaning: it used to allow unsafe code inside, now it says that calling the method is unsafe. It can only be called from an unsafe context, and the method body needs its own unsafe block. For places where a block can't go (field initializers), there is the unsafe(...) expression.
int number = 42;
int* pointer = &number; // no unsafe: declare a pointer and take an address
Console.WriteLine(sizeof(Guid)); // no unsafe: sizeof of an unmanaged type
unsafe { Console.WriteLine(*pointer); } // dereference: only in an unsafe block
Console.WriteLine(unsafe(Read(pointer))); // calling an unsafe method: unsafe expression
Console.WriteLine(Header.Signature);
static unsafe int Read(int* p)
{
unsafe { return *p; } // an unsafe method body is no longer an unsafe context
}
class Header
{
public static readonly int Signature = unsafe(ReadSignature());
static unsafe int ReadSignature()
{
unsafe
{
int raw = 0x1234;
int* p = &raw;
return *p;
}
}
}
16
42
42
4660
What the compiler says when the rules are broken. A dereference outside a block, including in the body of an unsafe method:
error CS9360: This operation may only be used in an unsafe context
Calling an unsafe method from ordinary code:
error CS9362: 'Read(int*)' must be used in an unsafe context because it is marked as 'unsafe'
With only LangVersion=preview and no updated-memory-safety-rules flag, the pointer relaxations apply, and unsafe on a method keeps its old meaning. But a method with a pointer in its signature still counts as unsafe to call:
error CS9363: 'Read(int*)' must be used in an unsafe context because it has pointers in its signature
And without preview, the same code runs into the familiar CS0214 ("Pointers … may only be used in an unsafe context") and CS8652 on unsafe(...): that feature is only available in preview so far.
The point of the redesign is to make unsafety visible along the call chain: whoever calls an unsafe method writes unsafe explicitly, so you can find every place in the code that is responsible for memory safety.
Summary
| Feature | What it gives you | Status |
|---|---|---|
union |
a value of one of several types, exhaustive switch |
shipped |
closed |
a closed class hierarchy, exhaustive switch |
shipped |
with(...) in [...] |
capacity, comparer and other collection constructor arguments | shipped |
indexers in extension |
x[i], x[^1], x[a..b], list patterns through extensions |
shipped |
break label;, continue label; |
leaving nested loops without flags or goto |
shipped |
| static interface members | work on .NET Framework and netstandard2.0 |
shipped |
new unsafe rules |
pointers without unsafe, unsafe on a method is for the caller |
preview |
Unions and closed fill a long-standing gap in the language: until now, a switch over a class hierarchy or over object without a _ arm got a warning even when every case was handled — the compiler couldn't know there were no other subclasses. Now "one of" can be expressed as a type, and when you add a new case the compiler finds every place that needs fixing.
Sources
- What's new in C# 15 — Microsoft Learn
- Union types — C# language reference
- closed — C# language reference
- Extension members — C# language reference
- Collection expression arguments — specification
- Labeled break and continue — specification
- Unsafe evolution — specification of the new memory safety rules
- Explore new features available in C# 15 preview — .NET Blog
- Announcing .NET 11 — .NET Blog
- .NET and .NET Core Support Policy — support dates
Only registered users can leave a comment.
Sign in or register to leave a comment.