.NET 11 Preview 3 is here, and it brings some of the most anticipated features C# developers have been asking for in years.
Union types are finally landing in C# 15 (Discriminated unions). We received a significant improvement in runtime for Async. And file-based apps can now be split across multiple files.
.NET 11 is expected to ship as an STS release later this year, so Preview 3 is a great time to start experimenting with these features in personal projects. And the STS version now is supported for 24 months.
In this post, we will explore:
- What's New in C# 15
- What's New in the .NET 11 Runtime
- What's New in the .NET 11 SDK
- What's New in System.Text.Json
- What's New in EF Core 11
- Other Changes in .NET 11 Preview 3
Let's dive in!
What's New in C# 15
Union Types
Union types have been one of the most frequently requested features in C# for years, and they are finally here.
Starting with .NET 11 Preview 2 and 3, C# 15 introduces the union keyword.
A union declares that a value is exactly one of a fixed set of types, with compiler-enforced exhaustive pattern matching.
If you have used discriminated unions in F#, Rust, or TypeScript, you will feel right at home.
Why do we need union types?
Before C# 15, when a method needed to return one of several possible types, you had imperfect options:
object- places no constraints on what types are actually stored. Any type could end up there, and the caller had to write defensive logic for unexpected values.- Marker interfaces or abstract base classes - better because they restrict the set of types, but they can't be "closed". Anyone can implement the interface or derive from the base class, so the compiler can never consider the set complete.
Both approaches require the types to share a common ancestor, which doesn't work when you want a union of unrelated types. Some devs used external libraries, it worked to some extent, but they had a lot of limitations when compared to natural feature implementations.
Union types solve all these problems. A union declares a closed set of case types - they don't need to be related to each other, and no other types can be added.
Let's look at a real-world example.
Example 1: Payment Methods
Say you are building a checkout service that accepts multiple payment methods. Each payment method carries different data:
csharppublic record class CreditCard(string Number, string Holder, DateOnly Expiry); public record class PayPal(string Email); public record class BankTransfer(string Iban, string Bic); public union PaymentMethod(CreditCard, PayPal, BankTransfer);
This single line declares PaymentMethod as a new type whose variables can hold a CreditCard, a PayPal, or a BankTransfer.
The compiler provides implicit conversions from each case type, so you can assign any of them directly:
csharpPaymentMethod payment = new CreditCard("4111111111111111", "John Doe", new DateOnly(2028, 12, 1));
The compiler issues an error if you assign an instance of a type that isn't one of the case types.
You can now process payments using a switch expression that is fully exhaustive:
csharpstring Process(PaymentMethod payment) => payment switch { CreditCard card => $"Charging card ending in {card.Number[^4..]}", PayPal pp => $"Redirecting to PayPal for {pp.Email}", BankTransfer b => $"Initiating bank transfer to {b.Iban}" };
Notice there is no _ or default branch.
The compiler knows all three case types and verifies that every one is handled.
If you later add a fourth case (for example, ApplePay), every switch expression that doesn't handle it produces a compiler warning.
That is the core value: the compiler catches missing cases at build time instead of runtime.
Example 2: Results
Unions are also great for replacing exception-based flows with explicit outcomes.
Imagine you are parsing a user-supplied amount into a decimal:
csharppublic record class ParseSuccess(decimal Value); public record class InvalidFormat(string Input); public record class OutOfRange(string Input, decimal Min, decimal Max); public union ParseResult(ParseSuccess, InvalidFormat, OutOfRange); public static ParseResult ParseAmount(string input, decimal min, decimal max) { if (!decimal.TryParse(input, out var value)) { return new InvalidFormat(input); } if (value < min || value > max) { return new OutOfRange(input, min, max); } return new ParseSuccess(value); }
The calling code must handle every case:
csharpvar message = ParseAmount(userInput, 0m, 10000m) switch { ParseSuccess s => $"Accepted: {s.Value:C}", InvalidFormat f => $"Not a number: {f.Input}", OutOfRange r => $"{r.Input} is outside [{r.Min}, {r.Max}]" };
No exceptions, no magic values, no forgotten branches.
Example 3: OneOrMore<T>
APIs sometimes accept either a single item or a collection. A union with a body lets you add helper members alongside the case types:
csharppublic union OneOrMore<T>(T, IEnumerable<T>) { public IEnumerable<T> AsEnumerable() => Value switch { T single => [single], IEnumerable<T> many => many, null => [] }; }
Callers pass whichever form is convenient:
csharpOneOrMore<string> tags = "dotnet"; OneOrMore<string> moreTags = new[] { "csharp", "unions", "preview" }; foreach (var tag in tags.AsEnumerable()) { Console.WriteLine(tag); }
Notice that the AsEnumerable method handles the case where Value is null.
The default null-state of the Value property is maybe-null, which is why the compiler requires you to handle it.
Null Handling
Patterns apply to the union's Value property, not the union struct itself.
When you write CreditCard card, the compiler checks Value for you.
The default value of a union struct has a null Value:
csharpPaymentMethod payment = default; var description = payment switch { CreditCard card => "credit card", PayPal pp => "paypal", BankTransfer b => "bank transfer", null => "no payment method" }; // description is "no payment method"
Custom Unions for Existing Libraries
The union declaration is an opinionated shorthand.
The compiler generates a struct with a constructor for each case type and a Value property of type object?.
But several community libraries already provide union-like types (like OneOf or ErrorOr).
Those libraries don't need to switch to the union syntax to benefit from C# 15.
Any class or struct marked with [System.Runtime.CompilerServices.Union] is recognized as a union type, as long as it follows the basic pattern: one or more public single-parameter constructors (defining the case types) and a public Value property.
For performance-sensitive scenarios, libraries can implement a non-boxing access pattern by adding a HasValue property and TryGetValue methods.
Closed Hierarchies (Champion Proposal)
Union types work great when the case types are unrelated. But sometimes you have a hierarchy of related types, and you want the same exhaustiveness benefits.
That is where closed hierarchies come in.
Allow a class to be declared closed.
This prevents derived classes from being declared in another assembly.
Example: Order State Machine
Say you are building an orders library. An order goes through several states during its lifecycle:
csharp// In the Orders library public closed record class OrderState; public record class Pending(DateTime CreatedAt) : OrderState; public record class Paid(DateTime PaidAt, string PaymentId) : OrderState; public record class Shipped(DateTime ShippedAt, string TrackingNumber) : OrderState; public record class Delivered(DateTime DeliveredAt) : OrderState; public record class Cancelled(DateTime CancelledAt, string Reason) : OrderState;
A consumer in a different assembly cannot derive new states:
csharp// In a consumer assembly public record class Refunded(DateTime RefundedAt) : OrderState; // ERROR: 'OrderState' is a closed class and cannot be derived outside its assembly
This means when you write a switch expression over OrderState, the compiler knows the complete set of derived types:
csharpstring description = state switch { Pending p => $"Waiting for payment since {p.CreatedAt}", Paid paid => $"Paid with {paid.PaymentId}", Shipped s => $"Shipped with tracking {s.TrackingNumber}", Delivered d => $"Delivered on {d.DeliveredAt}", Cancelled c => $"Cancelled: {c.Reason}" };
No default branch needed. The compiler verifies exhaustiveness.
If you add a new state (like Refunded) to the closed hierarchy later, every switch expression that doesn't handle it triggers a compiler warning.
That is the same benefit unions give you - but for type hierarchies.
A few rules for closed classes:
- A closed class is implicitly abstract. It cannot also have a
sealedorstaticmodifier. - A class derived from a closed class is not itself closed unless explicitly declared so.
Closed Enums (Champion Proposal)
Enums have a similar problem as class hierarchies. Nothing prevents you from creating enum values that weren't declared.
Allow an enum type to be declared closed.
Example: Subscription Tier
Let's say your SaaS has three subscription tiers:
csharppublic closed enum SubscriptionTier { Free, Pro, Enterprise }
A closed enum prevents creation of enum values other than the declared members:
csharpSubscriptionTier tier = 0; // OK - all closed enums have a member for value 0 tier = (SubscriptionTier)1; // OK - the constant 1 corresponds to Pro tier = (SubscriptionTier)10; // ERROR - no member for the value 10 tier = (SubscriptionTier)someInt; // ERROR - someInt is not a constant
Because the set of values is fixed, switch expressions over a closed enum are exhaustive without a default:
csharpdecimal monthlyPrice = tier switch { SubscriptionTier.Free => 0m, SubscriptionTier.Pro => 29m, SubscriptionTier.Enterprise => 199m };
Rules for closed enums:
- A closed enum must declare a member corresponding to the integral value 0.
- Explicit conversions are only allowed from constants that correspond to a declared member.
- Operators returning a closed enum are only allowed over constant operands.
Together, these three features give C# a comprehensive exhaustiveness story:
- Union types - exhaustive matching over a closed set of unrelated types
- Closed hierarchies - exhaustive matching over a sealed class hierarchy
- Closed enums - exhaustive matching over a fixed set of enum values
Union types are available now in .NET 11 Preview. Closed hierarchies and closed enums are still active proposals, but the direction is clear: C# is finally getting a complete exhaustiveness story.
Read the full specs: Union types, Closed hierarchies, and Closed enums.
Collection Expression Arguments
C# 12 introduced collection expressions with the [...] syntax.
C# 15 extends them with collection expression arguments.
You can now pass arguments to the underlying collection's constructor or factory method by using a with(...) element as the first element in a collection expression.
This enables you to specify capacity, comparers, or other constructor parameters directly within the collection expression syntax:
csharpstring[] values = ["one", "two", "three"]; // Pass capacity argument to List<T> constructor List<string> names = [with(capacity: values.Length * 2), .. values]; // Pass comparer argument to HashSet<T> constructor HashSet<string> set = [with(StringComparer.OrdinalIgnoreCase), "Hello", "HELLO", "hello"]; // set contains only one element because all strings are equal with OrdinalIgnoreCase
This is a small feature, but it removes a common friction point. Previously, you had to choose between the clean collection expression syntax and the ability to configure the collection. Now you can have both.
You can see the complete list of new features in C# 15 here.
What's New in the .NET 11 Runtime
Runtime Async V2
.NET 11 introduces runtime-native async (Runtime Async V2), a significant step toward replacing compiler-generated async state machines with runtime-managed suspension and resumption.
Instead of the compiler emitting state-machine classes, the runtime itself tracks async execution. The result is cleaner stack traces, better debuggability, and lower overhead.
Cleaner Live Stack Traces
The most visible improvement is in live stack traces - what profilers, debuggers, and new StackTrace() see during execution.
Consider this code:
csharpawait OuterAsync(); static async Task OuterAsync() { await Task.CompletedTask; await MiddleAsync(); } static async Task MiddleAsync() { await Task.CompletedTask; await InnerAsync(); } static async Task InnerAsync() { await Task.CompletedTask; Console.WriteLine(new StackTrace(fNeedFileInfo: true)); }
Without runtime async, you get 13 frames, with state-machine infrastructure mixed into your real call chain:
textat Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24 at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...) at Program.<<Main>$>g__InnerAsync|0_2() at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14 at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...) at Program.<<Main>$>g__MiddleAsync|0_1() at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8 at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...) at Program.<<Main>$>g__OuterAsync|0_0() at Program.<Main>$(String[] args) in Program.cs:line 3 at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...) at Program.<Main>$(String[] args) at Program.<Main>(String[] args)
With runtime async, you get 5 frames - just the real call chain:
textat Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24 at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14 at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8 at Program.<Main>$(String[] args) in Program.cs:line 3 at Program.<Main>(String[] args)
This improvement benefits anything that inspects the live execution stack:
- Profiling tools
- Diagnostic logging
- The debugger call stack window
Exception stack traces already look this clean today, because existing
ExceptionDispatchInfocleanup handles that case. The improvement is in what you see during live execution.
Runtime Async also reduces memory overhead because the runtime doesn't need to allocate state machine objects on each async call.
What's New in the .NET 11 SDK
File-Based Apps Can Be Split Across Files
.NET 10 introduced file-based apps with dotnet run main.cs, letting you execute a single C# file without a project.
It was a great step forward, but it had a limitation: your entire app had to fit in one file.
.NET 11 Preview 3 removes that limitation.
File-based apps now support the #:include directive, so shared helpers and models can move into separate files without giving up the file-based workflow.
Imagine you are writing a quick tool to process some customer data. In .NET 10, you had to put everything in one file, which quickly became hard to navigate.
Now you can split it like this:
csharp// helpers.cs public static class Helpers { public static string FormatOutput(Customer c) => $"[{c.Id}] {c.Name} ({c.Email})"; }
csharp// models/customer.cs public record Customer(int Id = 0, string Name = "", string Email = "");
csharp// main.cs #:include helpers.cs #:include models/customer.cs var customer = new Customer(1, "John Doe", "[email protected]"); Console.WriteLine(Helpers.FormatOutput(customer));
Then run it like a normal file-based app:
bashdotnet run main.cs
The #:include directive brings the referenced file into the compilation, just like a csproj would include source files.
Roslyn also adds editor completion for the directive, so you get intellisense when typing #:include in your main file.
This completes the file-based apps story from .NET 10.
You can now start with a single file, grow it into multiple files as your script matures, and convert it to a full project with dotnet project convert only when you truly need the overhead of a .csproj.
For CLI utilities, automation scripts, and internal tooling, this workflow is a real productivity boost.
What's New in System.Text.Json
.NET 11 Preview 3 expands the built-in naming and ignore options in System.Text.Json.
Three improvements land in one coherent story:
JsonNamingPolicy.PascalCaseadds to the existing camel, snake, and kebab presets.[JsonNamingPolicy]lets you override naming on individual members.- Type-level
[JsonIgnore(Condition = ...)]lets a model set its default ignore behavior in one place.
Here is a combined example:
csharpusing System.Text.Json; using System.Text.Json.Serialization; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public sealed class EventData { [JsonNamingPolicy(JsonKnownNamingPolicy.CamelCase)] public string EventName { get; set; } = ""; public string? Notes { get; set; } } var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.PascalCase }; var payload = new EventData { EventName = "user.signed_in", Notes = null }; var json = JsonSerializer.Serialize(payload, options);
Note what happens here:
- The global policy serializes most properties in PascalCase.
EventNamehas a per-member override, so it comes out in camelCase.Notesisnull, so it is skipped entirely - because the type-level[JsonIgnore]says so.
The output looks like this:
json{ "eventName": "user.signed_in" }
Before this release, you had to either set the ignore behavior on each property individually or configure it globally in JsonSerializerOptions.
Now you can do it in one place, on the type itself.
This is a small set of changes, but it makes common JSON configurations much more expressive.
What's New in EF Core 11
Removing Providers and Adding Pooled Factories
.NET 11 Preview 3 adds two helpers for DbContext configuration: RemoveDbContext() and RemoveExtension().
These helpers remove a previous provider configuration before registering a different one. They are especially useful in integration tests, where you want to swap out a production SQL Server configuration for an in-memory or SQLite provider:
csharpservices.RemoveDbContext<OrdersDbContext>(); services.AddDbContext<OrdersDbContext>(options => options.UseSqlite("DataSource=:memory:"));
Before this change, tests had to rebuild the service collection manually or use fragile workarounds. Now the swap is explicit and one-liner clean.
Preview 3 also adds a parameterless overload for AddPooledDbContextFactory<TContext>():
csharpservices.ConfigureDbContext<OrdersDbContext>(options => options.UseNpgsql(connectionString)); services.AddPooledDbContextFactory<OrdersDbContext>();
If your DbContext configuration already lives in ConfigureDbContext<TContext>(), you don't need to repeat it when registering the pooled factory.
Migrations Get More Control
EF Core 11 lets you exclude a foreign-key constraint from migrations with ExcludeForeignKeyFromMigrations(true):
csharpmodelBuilder.Entity<Order>() .HasOne(o => o.Customer) .WithMany(c => c.Orders) .ExcludeForeignKeyFromMigrations(true);
This is useful when you want EF to understand the relationship for querying purposes, but you manage the actual foreign key outside of EF migrations. Common scenarios include database-first workflows or cases where foreign keys are managed by another team or system.
The model snapshot now also records the latest migration ID. This helps EF detect diverged migration trees earlier in team workflows - for example, when two developers create migrations on different branches and then merge.
You can see the complete list of EF Core 11 features here.
.NET Container Images Are Now Signed
All .NET container images are now cryptographically signed by Microsoft according to the Notary Project specification.
You can verify signatures using the Notation CLI or the ORAS CLI:
bashnotation inspect mcr.microsoft.com/dotnet/sdk:11.0.100-preview.3 oras discover mcr.microsoft.com/dotnet/sdk:11.0.100-preview.3
Signed images help you verify that the images you pull haven't been tampered with. If you deploy .NET container images in production, this gives you a stronger supply chain story out of the box.
Summary
.NET 11 Preview 3 is a meaningful step forward even at the preview stage.
Union types are the biggest language feature C# has gained in years. Together with closed hierarchies and closed enums, they give C# a complete exhaustiveness story that catches missing cases at compile time, not at runtime.
Runtime Async V2 replaces compiler-generated state machines with runtime-managed suspension. Stack traces get cleaner and you get performance improvements for free.
File-based apps can now span multiple files, making C# scripts viable for real automation tools - not just one-big-file utilities.
System.Text.Json, EF Core, and container images all get quality-of-life improvements that add up when you use them every day.
.NET 11 is planned as an STS release later this year with 24 months of support, so Preview 3 is a good time to start experimenting with these features in personal projects and begin planning your migration strategy.
I have been testing union types in a side project and I am already seeing code that I couldn't write cleanly before.
Are you excited about .NET 11 as I am? Stay subscribed to my newsletter so you don't miss any new .NET updates.
Hope you find this newsletter useful. See you next time.
