C# Union Types: Migrating from OneOf in .NET 11 (2026 Guide)
C# 15 native union types just shipped in .NET 11 preview. Here is how they compare to the OneOf library, what migration actually looks like, and the boxing tradeoff to know about.
On this page
C# is getting native union types. After years of community requests, third-party workarounds, and a design process publicly tracked since 2024, the union keyword shipped in .NET 11 Preview 2 this April, with full runtime support landing by Preview 5. GA is expected with C# 15 in November 2026.
If you have used the OneOf NuGet package (and a huge number of C# codebases have, because it has been the de facto answer to this problem for years), this changes things. Here is exactly what changes, and what migrating actually looks like.
What Shipped, and When
The union keyword first appeared in .NET 11 Preview 2 (April 2026). Runtime types (UnionAttribute and IUnion) landed in Preview 5. The Microsoft Learn documentation was last updated June 11, 2026, and Microsoft's Build 2026 recap calls union types one of the headline .NET 11 features.
GA is expected with C# 15 / .NET 11 in November 2026. The syntax has already shifted between preview releases, so treat everything below as current-preview behavior that may still evolve before the final release.
The Problem OneOf Has Been Solving
Before any of this, if a method needed to return one of several possible types, C# developers had genuinely bad options. Throwing an exception for an expected outcome like "customer not found" is semantically wrong, because it is not exceptional, it is a normal result. Returning a nullable tuple plus a separate error string means callers have to remember which field to check, and the compiler gives no help if they forget.
Building a custom result class with a discriminator enum is the closest to correct, but it is boilerplate you write and maintain by hand, and the compiler still cannot tell you if you missed a case in a switch statement.
OneOf became the standard library answer: OneOf<T0, T1, T2> as a generic wrapper type, with .Match() and .Switch() methods that require a handler for every generic parameter.
OneOf vs Native Union: Side by Side
Here is the same method signature, three ways.
Hand-rolled (the pre-OneOf approach):
public abstract class OrderResult { }
public class Receipt : OrderResult { public int ReceiptId; }
public class InsufficientFunds : OrderResult { }
public class ProductNotFound : OrderResult { }
public OrderResult PlaceOrder(int productId, int payment) { ... }With OneOf:
public OneOf<Receipt, InsufficientFunds, ProductNotFound>
PlaceOrder(int productId, int payment) { ... }
// Calling code
result.Switch(
receipt => Console.WriteLine($"Order placed: {receipt.ReceiptId}"),
funds => Console.WriteLine("Insufficient funds"),
notFound => Console.WriteLine("Product not found")
);With a native union:
public union OrderResult(Receipt, InsufficientFunds, ProductNotFound);
public OrderResult PlaceOrder(int productId, int payment) { ... }
// Calling code
var message = result switch
{
Receipt r => $"Order placed: {r.ReceiptId}",
InsufficientFunds => "Insufficient funds",
ProductNotFound => "Product not found",
};The native version reads like ordinary C# pattern matching, because it is. No .Switch() API to learn, no generic type parameter list to keep in sync with your handler lambdas.
| OneOf Library | Native Union (C# 15) | |
|---|---|---|
| Declaration syntax | OneOf<T0, T1, T2> (generic params) | union Name(T0, T1, T2) |
| Case handling | .Match() / .Switch() lambdas | switch expression (standard C#) |
| Exhaustiveness | API-level (.Match requires all lambdas) | Compiler-level (switch must cover all cases) |
| IDE support | Basic (NuGet package) | Full (language-level, IntelliSense, analyzers) |
| Requires NuGet package | Yes | No (built into the language) |
| Min framework | .NET Standard 2.0+ | .NET 11+ (preview) |
Migrating a Real OneOf Method
Take an existing method:
public OneOf<Success<User>, NotFound, ValidationError>
GetUser(int id) { ... }Convert the type declaration first:
public union UserResult(Success<User>, NotFound, ValidationError);
public UserResult GetUser(int id) { ... }Then convert every call site. A .Match() call becomes a switch expression:
// Before (OneOf)
var response = result.Match(
success => Ok(success.Value),
notFound => NotFound(),
error => BadRequest(error.Message)
);
// After (native union)
var response = result switch
{
Success<User> s => Ok(s.Value),
NotFound => NotFound(),
ValidationError e => BadRequest(e.Message),
};The conversion is mostly mechanical for .Match() calls returning a value. .Switch() calls (void, side-effecting) convert the same way but without the assignment.
The Exhaustiveness Difference
OneOf enforces exhaustiveness through its API shape: .Match() literally requires a lambda parameter for every generic type argument, so you cannot compile if you forget one. But .Switch() calls written by hand, or any code that manually inspects .IsT0 / .IsT1 properties with an else fallback, can silently swallow a new case if someone later adds a fourth generic parameter to the OneOf<> declaration and forgets to update every consumer.
Native unions enforce exhaustiveness at the compiler level for switch expressions, full stop. Add a fourth case type to your union declaration, and every switch expression handling that union anywhere in your codebase fails to compile until you add a case for it. This is a meaningfully stronger guarantee than OneOf's runtime-API-shaped enforcement.
The Boxing Tradeoff
This is the detail that matters most for performance-sensitive migrations. Under the hood, a union is a generated struct holding a single object? property. If every case type in your union is a reference type (a class), storing a value just stores that one reference with no boxing.
But if you mix value types (structs) with reference types in the same union, say union Result(int, ErrorInfo) where ErrorInfo is a class, the int case gets boxed to fit into that object? slot. This is functionally correct but has the usual boxing cost: a heap allocation and GC pressure you would not have with a plain int return.
How to Try It Today
Install the .NET 11 Preview SDK, then configure your project file:
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>As of Preview 5, the UnionAttribute and IUnion interface ship in the runtime, so you do not need to hand-declare them as you did in earlier previews. IDE support is available in current Visual Studio Insiders builds and the latest C# DevKit Insiders build.
If you are running CI with GitHub Actions, add the preview SDK to your setup-dotnet step and ensure your test commands pass the preview flag. Similar to how teams approached the TypeScript 7 migration, experiment in a branch before committing production code.
Should You Migrate Now?
If you are starting a new project or an experimental branch, native unions are genuinely worth using today. They are more concise than OneOf, and the compiler-enforced exhaustiveness is a real improvement. Report any rough edges to the C# team; previews exist specifically to gather this kind of feedback before the syntax locks in at GA.
For existing production code currently using OneOf: there is no urgency. OneOf works correctly today, has years of battle-testing, and the preview syntax has already shifted between releases this year. Wait for the C# 15 / .NET 11 GA release in November 2026 before planning a real migration. By then the final syntax and runtime behavior will be locked, and you will do the conversion once instead of chasing a moving target.
Frequently Asked Questions
Frequently Asked Questions
Is C# union the same as OneOf?
They solve the same problem: representing "this value is exactly one of these types." The key differences are where they live and how they enforce correctness. OneOf is a third-party NuGet package that uses generic type parameters and runtime API patterns (.Match(), .Switch()). Native unions are a language feature with a dedicated union keyword, compiler-enforced exhaustiveness in switch expressions, and full IDE integration.
For most use cases, a native union is a direct replacement for an equivalent OneOf declaration. The main migration consideration is the boxing tradeoff for unions mixing value and reference types.
Do C# unions cause boxing?
Only when value types (structs) and reference types (classes) are mixed in the same union. A union of all reference types stores one reference with no boxing. A union of all value types also avoids boxing when the compiler can use a specialized layout. Mixing the two causes value type cases to be boxed into the internal object? storage.
For most business logic (where case types are classes like Success, NotFound, ValidationError), boxing is not a concern.
When will C# union types be stable?
GA is expected with C# 15 / .NET 11 in November 2026. The union keyword first appeared in .NET 11 Preview 2 (April 2026), and runtime support landed in Preview 5. The syntax has evolved between previews, so the final GA syntax may differ slightly from current preview behavior.
Can I use union types in .NET 10?
No. Native union types require .NET 11 (currently in preview). .NET 10 does not include the union keyword or the UnionAttribute / IUnion runtime types. If you are on .NET 10 or earlier and need this pattern, continue using the OneOf NuGet package.
What is the difference between a union and a discriminated union in C#?
In C# 15, the terms are effectively synonymous. The union keyword declares what functional programming languages call a "discriminated union" or "tagged union": a type that is exactly one of a closed set of case types, with a compiler-tracked discriminator so pattern matching can determine which case a value holds.
The "discriminated" qualifier distinguishes these from C/C++ unions, which share memory without tracking which member is active. C# unions always know which case is active, making them safe to pattern match against.
Is there a tool to convert OneOf code to native unions?
Yes. OneOfToUnion converts OneOf<T0, T1, ...> type declarations and .Match() / .Switch() call sites to the equivalent native union declaration and switch expression code. It runs entirely in your browser and produces a starting-point conversion that you should always review and compile before committing.
Related Articles
TypeScript 7 Migration Guide: tsgo, Breaking Changes, Build Times
Migrate to TypeScript 7 (tsgo): install the beta, fix the 4 breaking changes, update tsconfig, and decide if upgrading now is worth it.
GitHub Actions Security: 7 Misconfigurations to Avoid
The 7 GitHub Actions misconfigurations behind real supply chain attacks: weak GITHUB_TOKEN scope, pull_request_target, unpinned actions, script injection.