newsletter

New Features in .NET 11 Preview 3

9 min read

.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!

Copied

What's New in C# 15

Copied

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:

csharp
public 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:

csharp
PaymentMethod 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:

csharp
string 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:

csharp
public 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:

csharp
var 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:

csharp
public 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:

csharp
OneOrMore<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:

csharp
PaymentMethod 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.

Copied

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:

csharp
string 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 sealed or static modifier.
  • A class derived from a closed class is not itself closed unless explicitly declared so.
Copied

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:

csharp
public closed enum SubscriptionTier { Free, Pro, Enterprise }

A closed enum prevents creation of enum values other than the declared members:

csharp
SubscriptionTier 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:

csharp
decimal 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.

Copied

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:

csharp
string[] 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.

Copied

What's New in the .NET 11 Runtime

Copied

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:

csharp
await 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:

text
at 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:

text
at 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 ExceptionDispatchInfo cleanup 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.

Copied

What's New in the .NET 11 SDK

Copied

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:

bash
dotnet 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.

Copied

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.PascalCase adds 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:

csharp
using 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.
  • EventName has a per-member override, so it comes out in camelCase.
  • Notes is null, 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.

Copied

What's New in EF Core 11

Copied

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:

csharp
services.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>():

csharp
services.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.

Copied

Migrations Get More Control

EF Core 11 lets you exclude a foreign-key constraint from migrations with ExcludeForeignKeyFromMigrations(true):

csharp
modelBuilder.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.

Copied

.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:

bash
notation 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.

Copied

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.

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook (launching soon) — 800+ real-world .NET interview questions with expert answers. Crush your next interview and close every knowledge gap. Waitlist subscribers get an exclusive discount not available after launch.

Join the waitlist

Enjoyed this article? Share it with your network

Improve Your .NET and Architecture Skills

Join my community of 25,000+ developers and architects.

Each week you will get 1 practical tip with best practices and real-world examples.

Learn how to craft better software with source code available for my newsletter.

Join 25,000+ developers already reading
No spam. Unsubscribe any time.