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.
Pattern Matching Ergonomics
Replacing .Match() with a switch expression is the mechanical part of the migration. The part you feel a week later is that a union is now an ordinary value in the pattern matching grammar, so every pattern feature C# already has applies to it.
Property patterns, relational patterns, when guards, tuple patterns, and nested patterns all compose. With OneOf you get a lambda per case and then write the interesting logic inside that lambda. With a union you can express the branch condition in the pattern itself.
var message = result switch
{
Receipt { Total: > 1000 } r => $"Large order {r.ReceiptId} held for review",
Receipt r => $"Order placed: {r.ReceiptId}",
InsufficientFunds f when f.Shortfall < 5m
=> "Almost there, top up and retry",
InsufficientFunds => "Insufficient funds",
ProductNotFound => "Product not found",
};Two things are worth noticing. The Receipt case appears twice, and the compiler still treats the union as fully covered because the unguarded Receipt r arm catches everything the guarded arm does not. Arm order matters here exactly as it does in any other switch expression: the specific pattern has to come first, or the general one swallows it.
Unions also work with is patterns, so a single-case check does not need a switch at all. if (result is ProductNotFound) return NotFound(); reads better than if (result.IsT2), and it does not silently change meaning when someone reorders the case types in the declaration.
That last point is easy to underrate. Positional accessors like .IsT0 and .AsT1 couple every call site to the declaration order of the generic parameters. Reordering them is a source-compatible change that compiles cleanly and breaks behaviour at runtime. Named case types remove that entire failure mode.
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.
Performance and Allocation vs OneOf
OneOf and native unions both avoid heap allocation in the common case, but they get there differently, and the difference shows up in profiles of hot paths.
OneOf<T0, T1, T2> is a readonly struct that carries a separate field for every case plus an integer index. That layout never boxes, because each case has its own correctly typed slot, but the struct grows with the number of cases. A five-case OneOf is roughly six machine words wide, and it is copied by value at every method boundary, every array element write, and every closure capture.
A native union is a struct wrapping a single object?. Its size does not change with the number of cases, so passing one around costs a single pointer copy no matter how wide the union is. The price is the boxing described above for value type cases.
| Characteristic | OneOf<T0..Tn> | Native union |
|---|---|---|
| Struct width | Grows with case count (one field per case) | One reference, constant |
| Reference type cases | No boxing | No boxing |
| Value type cases | No boxing | Boxed into the internal object? slot |
| Copy cost at call sites | Proportional to case count | One pointer, regardless of case count |
| Extra allocation per value | None | One box per value type case stored |
The practical reading is that native unions are cheaper for wide unions of classes and more expensive for unions carrying int, Guid, DateTime, or your own structs. If a union sits in a request path running a few thousand times a second, boxing is noise against the cost of the surrounding I/O. If it sits inside a parser loop or a serialization pipeline running millions of times a second, measure before you convert.
Wrapping a value type case in a small class is not a free workaround, because that swaps a box for an allocation of the same order. It only pays off if you can cache or pool the wrappers. For genuinely allocation-critical code the honest answer is still a hand-written struct with an explicit discriminator field, the same as it was before either option existed.
If you enjoy reasoning about layout costs like this, the same problem is being attacked from the other direction on the JVM. The Project Valhalla value classes work is largely about letting the runtime avoid exactly the identity-and-boxing overhead that C# unions are paying here.
Serializing Unions with System.Text.Json
This is the part that catches teams out, because it is not mentioned in the syntax announcements and it does not fail loudly. A union is a struct whose only storage is a single object?. System.Text.Json serializes public members, so serializing a union with default options does not produce the shape you expect and does not round trip.
You need a custom converter, exactly as you did with OneOf. The good news is that the converter is easier to write than the OneOf equivalent, because you can use a checked switch expression to pick the discriminator instead of chaining .IsT0 tests.
Pick an envelope shape first, then apply it everywhere. A tagged object is the usual choice because it is unambiguous, self describing, and survives adding a case later without breaking existing readers.
{
"case": "ValidationError",
"value": { "field": "email", "message": "Not a valid address" }
}Writing is a switch expression that produces the tag and the payload, then a few writer calls. Reading is the mirror image: read the tag, deserialize the payload into the matching case type, and let the implicit conversion produce the union.
public override void Write(
Utf8JsonWriter writer, UserResult value, JsonSerializerOptions options)
{
var (tag, payload) = value switch
{
Success<User> s => ("Success", (object?)s),
NotFound => ("NotFound", null),
ValidationError e => ("ValidationError", e),
};
writer.WriteStartObject();
writer.WriteString("case", tag);
if (payload is not null)
{
writer.WritePropertyName("value");
JsonSerializer.Serialize(writer, payload, options);
}
writer.WriteEndObject();
}Register the converter once on your JsonSerializerOptions and the shape stays consistent across your whole API. Add a new case to the union later and the switch expression inside the converter fails to compile, which is precisely the reminder you want.
If you already use [JsonDerivedType] polymorphic serialization for an abstract base class, note that it does not apply here. A union is not a class hierarchy, and its case types share no base type, so there is no polymorphic contract to hang the attribute on. That is also why you cannot swap a base class for a union and expect the wire format to stay the same.
Whatever envelope you pick, write a round trip test per union before converting anything that crosses a network boundary. Serialization is the area most likely to shift between the .NET 11 previews and GA, so treat these converters as code you will revisit rather than write once.
Unions as a Result Type, Compared with Exceptions
Most OneOf usage in the wild is really a hand-rolled Result type, and the same will be true of the union code you write. It is worth being deliberate about where that pattern earns its keep, because the alternative is not obviously worse.
C# has no checked exceptions, so a method signature tells you nothing about how the method can fail. A union puts the failure modes in the signature where the compiler and the reader can both see them. UserResult GetUser(int id) announces three outcomes up front, and the caller cannot quietly ignore two of them.
The cost is that unions do not unwind. An exception jumps straight to whichever frame wants to handle it, while a union has to be matched, remapped, and returned at every level in between. In a five-frame call chain where only the top frame cares, exceptions are less code and less ceremony.
| Situation | Prefer | Why |
|---|---|---|
| Expected domain outcome (not found, invalid input, insufficient funds) | Union | Callers must handle it, and the compiler proves that they did |
| Programming error (null argument, broken invariant, bad state) | Exception | The caller cannot recover, and the stack trace is the diagnostic |
| Infrastructure failure deep in a call chain | Exception | Unwinding avoids threading a result through frames that do not care |
| API boundary mapping outcomes to status codes | Union | The mapping becomes one switch expression the compiler checks |
| Hot loop where failure is a common, expected path | Union | Throwing is expensive; matching a case is not |
The split most teams settle on is unions at the boundaries and exceptions in the middle. Domain services return a union so the HTTP layer can map every case to a status code in one checked switch, while genuinely exceptional conditions keep throwing and get caught by middleware. Designing that boundary is an API contract question more than a language question, which is why it comes up so often in full stack system design interviews.
One warning from teams who have done this with OneOf: resist the urge to convert every method to return a result type. A union in a signature is a promise to the caller that these outcomes matter. If half of your codebase returns Result<T> and every caller immediately unwraps it and throws, you have added ceremony without adding information.
Unions and Nullable Reference Types
A union does not implicitly include null. Because it is a struct, with nullable reference types enabled it behaves like any other non-nullable value type: you cannot assign null to it, and a switch expression over it does not need a null arm.
You can still write UserResult?, and this is where people get themselves into trouble. A nullable union has one more state than it has cases, and that state means nothing in particular. Was the value not computed yet? Did something fail upstream? The type does not say, and now every call site needs a null check in front of the switch.
// Avoid: null becomes a fourth, undocumented outcome
public UserResult? TryGetUser(int id) { ... }
// Prefer: absence is a case with a name
public union UserResult(Success<User>, NotFound, ValidationError);
public UserResult GetUser(int id) { ... }The rule is simple. If absence is a meaningful outcome, give it a case type. That is the entire point of the feature. Keeping null for genuinely unknown or uninitialised values leaves the union's case list as the single source of truth for what can happen.
Nullability inside the case types is a separate question and behaves normally. A Success<User> whose User has a string? MiddleName is fine, and flow analysis follows through the pattern match, so Success<User> s gives you a non-null s inside that arm without a null-forgiving operator.
This is the same "make illegal states unrepresentable" discipline that separates good class design from a bag of nullable properties, a theme that runs through most OOP interview questions for a reason.
Running OneOf and Native Unions Side by Side
Nothing stops the two coexisting. OneOf is an ordinary NuGet package and a union is a language construct, so one assembly can use both while you convert. That matters, because a big-bang migration of a codebase with hundreds of .Match() call sites is not reviewable by anyone.
The cheapest interop is a pair of adapter methods per type, converting in whichever direction a given boundary needs. Convert the leaf types first, then work outwards, deleting an adapter each time both sides of a boundary have moved.
// Bridge while both shapes exist in the codebase
public static UserResult ToUnion(
this OneOf<Success<User>, NotFound, ValidationError> o) =>
o.Match<UserResult>(
success => success,
notFound => notFound,
error => error);
public static OneOf<Success<User>, NotFound, ValidationError> ToOneOf(
this UserResult u) =>
u switch
{
Success<User> s => s,
NotFound n => n,
ValidationError e => e,
};Both directions lean on an implicit conversion from a case type to the wrapper. OneOf has shipped those operators for years. The union side relies on the compiler-generated conversion for each declared case, so if a later preview tightens that behaviour, an explicit construction replaces the bare success return and the shape of the adapter is unchanged.
Two rules keep this from turning into a mess. First, do not change a public API surface in a shared library mid-migration. Add a union-returning member with a new name, mark the OneOf-returning one [Obsolete], and let consumers move on their own schedule. Second, delete adapters as soon as both sides have converted, because an adapter that outlives its purpose becomes the thing new code calls by default.
Keep progress visible with a CI step that simply counts remaining OneOf< occurrences and prints the number. A count that drops every sprint is a far better migration signal than a spreadsheet someone updates by hand, and it makes an abandoned migration obvious within a month rather than a year.
A Staged Migration Plan
Assuming GA has landed and you have decided to go ahead, this is the order that minimises risk. Each stage is independently shippable, which is the property that matters most when a migration has to share a roadmap with feature work.
- 1
Audit every OneOf usage
Count declarations and call sites separately. Declarations are cheap to convert; call sites are where the actual work lives. Flag which unions cross a serialization boundary, which mix value and reference types, and which appear in a public API surface.
Those three groups need decisions from a human. Everything left over is mechanical, and knowing the ratio up front is what makes the estimate believable.
- 2
Convert one leaf module end to end
Pick a module with no external consumers and a handful of call sites. Convert the declaration, the call sites, the tests, and any converters in a single pull request, and get it reviewed by someone who has not read the union spec.
The goal is to find out what your codebase specifically dislikes about unions before you commit to another hundred pull requests of the same shape.
- 3
Settle the wire format
Write the JSON converters and their round trip tests before converting anything that leaves the process. Lock one envelope shape and reuse it everywhere, so you are not renegotiating a format per union six months from now.
- 4
Sweep the internal code
Now do the bulk, module by module, with the CI counter tracking remaining usages. Convert
.Match()calls to switch expressions and resist adding a discard arm to silence the exhaustiveness error, because that error is doing your review for you.Ship each module separately. A conversion bug that lands in isolation takes minutes to bisect; the same bug inside a fifty-file pull request takes a day.
- 5
Convert the public surface last
Public APIs go last because they are the only stage with a cost outside your repository. Add union-returning members alongside the OneOf ones, mark the old ones obsolete with a message pointing at the replacement, and give consumers at least one release before removing them.
- 6
Remove the package and the adapters
When the counter hits zero, delete the adapters, drop the OneOf package reference, and remove any analyser suppressions you added during the pilot. Leaving the package installed invites new code to reach for it out of muscle memory, and then the migration never actually ends.
- Every union declaration has a documented purpose and a stable case list
- No switch expression over a union contains a discard arm
- No union on a hot path mixes value and reference type cases
- Every union crossing a process boundary has a converter and a round trip test
- Public APIs expose union members alongside obsolete OneOf ones
- CI reports the remaining count of OneOf usages on every build
- Adapters are deleted as soon as both sides of a boundary convert
When Not to Migrate
Migration is not free, and for a good number of codebases the correct answer is to stay on OneOf indefinitely. These are the cases where the arithmetic does not work out.
- You target .NET Standard 2.0, .NET Framework, or anything below .NET 11. Native unions are not backported, and OneOf runs everywhere.
- You ship a library other people consume. Changing a public signature breaks every downstream project, and forcing consumers onto .NET 11 to take a patch release is not a trade most of them will accept.
- Your unions carry value types on a hot path. The boxing is real, and a hand-written struct with an explicit discriminator still beats both options.
- Your OneOf usage is small. Twenty call sites across a solution are not costing you anything measurable, and the migration budget buys more somewhere else.
- You are already mid-migration on something else. A framework upgrade and a result-type rewrite in the same quarter makes every regression twice as hard to attribute.
None of this is an argument against the feature. It is an argument for treating it as a normal library-level decision rather than an upgrade you somehow owe the language. OneOf keeps working, and code you shipped last year does not become wrong the day unions reach GA.
The reverse framing is just as useful. If you are writing new .NET 11 code from scratch, there is little reason to take a package dependency for something the compiler now does better and checks more strictly. Reach for OneOf only when one of the constraints above actually applies. Anyone arriving from a language where closed sets of cases were always built in, Rust in particular, will find the native version much closer to what they expect than the generic wrapper was; the same modelling instinct shows up throughout Rust's trait and enum design.
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
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.
How do I serialize a C# union type with System.Text.Json?
You write a custom JsonConverter<T>. A union's only storage is a private object?, and System.Text.Json serializes public members, so the default behaviour does not give you a shape that round trips.
The standard approach is a tagged envelope: one property holding the case name and one holding the payload. Writing it is a switch expression over the union that yields the tag and the value, which means the compiler flags the converter the moment you add a case.
{
"case": "NotFound",
"value": null
}Polymorphic serialization via [JsonDerivedType] does not help here, because a union is not a class hierarchy and its case types share no base type. Write round trip tests for every union that crosses a network boundary before you migrate it.
Are C# union types slower than OneOf?
It depends on the case types. For a union of reference types, native unions are usually cheaper: the struct is one pointer wide regardless of how many cases you declare, while a OneOf<> struct carries a field per case plus an index and is copied by value at every call boundary.
For unions mixing value and reference types, native unions are more expensive, because each value type case is boxed into the internal object? slot. OneOf gives each case its own typed field and never boxes. On a hot path that matters; in a typical request handler it does not. Benchmark rather than guess.
Can I use OneOf and native union types in the same project?
Yes. OneOf is a NuGet package and a union is a language construct, so they coexist in the same assembly with no conflict. This is what makes a gradual migration possible.
Write small adapter extension methods to convert between the two at whichever boundaries still need it, convert leaf modules first, and delete each adapter once both sides have moved. Add a CI step that counts remaining OneOf< occurrences so an abandoned migration is visible immediately.
Should union types replace exceptions in C#?
No, they cover different situations. Use a union for expected domain outcomes such as not found, invalid input, or insufficient funds, where the caller has a real decision to make and the compiler should force them to make it. Use exceptions for programming errors and infrastructure failures, where the caller cannot meaningfully recover and the stack trace is the useful artefact.
The practical pattern is unions at the boundaries and exceptions in the middle. A domain service returns a union so the HTTP layer maps every case to a status code in a single checked switch, while genuinely exceptional conditions throw and get handled by middleware. Converting every method in a codebase to return a result type adds ceremony without adding information.
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.