C# 15 Union Types: The Easy Guide With Real Examples
Union types in C# 15 (.NET 11) let one method safely return one of many shapes. Learn how they work with simple, real-life examples, diagrams, and a clear comparison with OneOf.
Think of a parcel that holds only one thing
Imagine a delivery boy hands you a parcel. Before you open it, you already know one important rule: this parcel contains either a phone, or a book, or a watch. It will never contain two of them at the same time, and it will never contain something random like a brick. It is always exactly one item from that fixed list.
That simple idea — "this thing is one of a known set, and nothing else" — is exactly what union types in C# 15 give us. Until now, C# had no clean way to say it. With .NET 11 and C# 15, we finally can.
In this guide we will go slow and use everyday examples. By the end you will understand what a union type is, why it makes your code safer, and how to use it in a real .NET project. No heavy theory. Just simple English and small, clear code.
The problem we had before unions
A method in C# can return only one type. But real programs are full of situations where a method can finish in more than one way.
Think about a function that looks up a user by id:
- It might find the user and return it.
- Or the user might not exist.
How did we handle this before? We had a few options, and honestly none of them were clean.
// Option 1: throw an exception for the "not found" case
public User GetUser(int id) =>
_users.TryGetValue(id, out var user)
? user
: throw new UserNotFoundException(id);The method signature says it returns a User. But that is a small lie. It might also throw. The person calling this method cannot know that just by looking at the signature — they have to open the code and read it. And if they forget to catch the exception, the app crashes.
Other old tricks were:
- Return
null(and hope nobody forgets the null check — leading to the famousNullReferenceException). - Use a
bool TryGet(...)with anoutparameter (clumsy to read). - Build a custom
Resultclass by hand (lots of boilerplate). - Add a library like OneOf (good, but a third-party dependency, and it checks mistakes only when the program runs).
The real wish was simple: we wanted to say "this method returns a User or a NotFound, and the caller must deal with both." That is the gap union types fill.
What a union type actually is
A union type is a single type that can hold exactly one value from a fixed, known list of types.
The keyword is union. You give it a name and list the types it can be:
public record Cat(string Name);
public record Dog(string Name);
public record Bird(string Name);
// A Pet is a Cat OR a Dog OR a Bird — and nothing else.
public union Pet(Cat, Dog, Bird);That one line is doing a lot of quiet work for you:
- It creates a closed list. A
Petcan only ever be one of those three. - The compiler now knows the complete set of possibilities.
- Later, when you read a
Pet, the compiler will check that you handled every case.
A "closed set" is the magic word. Because the list is fixed and the compiler knows it fully, the compiler can protect you. This is the main reason unions are safer than older tricks.
Creating a value
You do not need any special syntax to put a value in. The compiler gives you automatic (implicit) conversions from each case type:
Pet pet = new Dog("Rex"); // works
pet = new Cat("Whiskers"); // also worksIf you try to assign something that is not in the list, you get a clear compile error — not a runtime surprise.
Pet pet = new Fish("Nemo"); // ❌ compile error: Fish is not part of PetThe box stays closed. This is exactly the parcel rule from the start of the article.
Reading a union: the part that shines
This is where unions really pay off. You read a union using a switch expression, and the compiler forces you to handle every case.
string Speak(Pet pet) => pet switch
{
Dog d => $"{d.Name} says Woof",
Cat c => $"{c.Name} says Meow",
Bird b => $"{b.Name} says Tweet",
};Look closely: there is no default branch and no _ discard at the bottom. We did not need them, because we covered all three cases and the compiler can see that.
Now imagine a teammate adds a new pet next month:
public union Pet(Cat, Dog, Bird, Rabbit); // added RabbitThe moment they do this, the compiler will point at every switch in the whole codebase that forgot about Rabbit:
warning CS8509: The switch expression does not handle all possible values
of its input type (it is not exhaustive). For example, the pattern 'Rabbit'
is not covered.This is called exhaustiveness checking, and it is a quiet superpower. The compiler becomes your reviewer. You can never silently forget a case again.
A real-life example: a food delivery order
Let us leave pets and use something we all know — ordering food on an app like Swiggy or Zomato.
When you place an order, the result is one of a few clear outcomes:
- The order is accepted and you get an order id.
- The restaurant is closed.
- Your payment failed.
We can model that honestly with a union:
public record OrderAccepted(string OrderId, int EtaMinutes);
public record RestaurantClosed(string OpensAt);
public record PaymentFailed(string Reason);
public union OrderResult(OrderAccepted, RestaurantClosed, PaymentFailed);Now the method that places the order tells the full truth in its signature:
public OrderResult PlaceOrder(Cart cart)
{
if (!_restaurant.IsOpen)
return new RestaurantClosed(_restaurant.OpensAt);
if (!_payment.Charge(cart.Total))
return new PaymentFailed("Card declined");
var id = _orders.Save(cart);
return new OrderAccepted(id, EtaMinutes: 35);
}And the screen that shows the result must handle every outcome — the compiler insists:
string Message = PlaceOrder(cart) switch
{
OrderAccepted ok => $"Order placed! Arriving in {ok.EtaMinutes} min.",
RestaurantClosed rc => $"Sorry, the kitchen opens at {rc.OpensAt}.",
PaymentFailed pf => $"Payment problem: {pf.Reason}. Please try again.",
};Notice how readable this is. Anyone — even a new junior developer joining your team — can look at the union and instantly understand every way an order can end. There are no hidden exceptions and no forgotten null checks.
How unions compare with the old ways
Here is a simple side-by-side so you can see why unions are a step forward.
| Approach | Tells the truth in the signature? | Forces you to handle all cases? | Needs a library? |
|---|---|---|---|
| Throw an exception | No | No | No |
Return null | No | No (easy to forget) | No |
bool TryGet + out | Partly | No | No |
Hand-written Result class | Yes | No (you must remember) | No |
| OneOf library | Yes | At runtime only | Yes |
| C# 15 union | Yes | At compile time | No |
The last row is the win. A union is honest in the signature and the compiler checks your cases before the program even runs. That is the safest spot on the table.
A note on performance (boxing)
Unions are wonderful, but it helps to know one small detail under the hood.
By default, a union stores its value as a single object?. That means if one of your cases is a value type (like int or a struct), it gets "boxed" — wrapped into an object on the heap. For most apps this is completely fine and you will never notice it.
Boxing matters only in very hot code paths — for example, a loop running millions of times per second. If you are writing such code, measure first with a benchmark before worrying about it. For normal web APIs and business apps, the default behaviour is perfectly good.
Moving from the OneOf library
Many .NET teams already use the OneOf NuGet package. The good news is that the ideas map almost one-to-one, so moving over is gentle.
// Before — using the OneOf library
public OneOf<User, NotFound> GetUser(int id) { /* ... */ }
// After — using a native C# 15 union
public record NotFound(int Id);
public union UserResult(User, NotFound);
public UserResult GetUser(int id) =>
_users.TryGetValue(id, out var user)
? user
: new NotFound(id);The big difference: with OneOf, if you forgot a case you would only find out when the code ran and threw a MatchFailureException. With native unions, you find out at compile time, which is far earlier and far cheaper to fix.
When you should NOT reach for a union
Unions are a tool, not a hammer for everything. Skip them when:
- There is genuinely only one outcome. A method that always returns a
Usershould just return aUser. - The "extra" case is a real, unexpected crash (like the database server being down). True exceptions are still the right tool for truly exceptional, unrecoverable problems.
- You are on an older .NET version. Unions need .NET 11 and C# 15. On .NET 8 or 9, keep using the Result pattern or OneOf.
A good rule of thumb: use a union when a method has a small, known set of normal outcomes that the caller should think about — like our order example.
Bonus: unions work beautifully with generics
You can make a union generic, which lets you reuse one shape across your whole app. A very common one is a general "result" type:
public record Error(string Message);
// A Result is either a value of type T, or an Error.
public union Result<T>(T, Error);Now any method can use it, and the caller always handles both the success and the failure in one clean place:
Result<decimal> balance = GetBalance(accountId);
string text = balance switch
{
decimal amount => $"Your balance is ₹{amount}",
Error err => $"Could not load balance: {err.Message}",
};One small Result<T> union can replace a pile of try/catch blocks scattered across your code, and it reads almost like plain English.
How to try it today
Union types are still a preview feature, so the steps are a little manual for now:
- Install the .NET 11 preview SDK (Preview 2 or later).
- Target
net11.0in your project. - Turn on preview language features in your
.csproj:
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>In the early previews you may also need to declare the UnionAttribute and IUnion types in your own project, because they were not yet built into the runtime. Later previews add them for you. Because this is preview, the exact syntax can still change before the final C# 15 release — so use it for learning and experiments, not yet for critical production code.
Where unions fit best
Unions are not for every situation. They shine when a value is genuinely "one of a small, fixed set." Here is a quick guide to common shapes:
| Use case | Good fit for a union? |
|---|---|
| A result that is a value or an error | Yes |
| "Found" or "not found" lookups | Yes |
| A payment that is card or UPI or bank transfer | Yes |
| An open-ended list of many unrelated types | No — too broad |
| A value that is always exactly one type | No — just use that type |
And here is how to decide, step by step:
Should this be a union?
Steps
Look at the value
What shapes can it really take?
A few types?
2 to 5 distinct shapes is the sweet spot
Closed set?
No new types appear at runtime
Union!
Declare union Name(A, B, C) and match on it
Under the hood, a union stores its value in a small wrapper. Picturing this helps you understand the boxing note from earlier:
The full journey of a union, in one picture
From the moment you declare a union to the moment your code handles it, there are four simple stages. Here they are at a glance:
Lifecycle of a Union Value in C# 15
Steps
Declare
Define the closed set: union Pet(Cat, Dog, Bird)
Assign
Put in exactly one case — converted for you
Match
Use a switch to read the value
Handle
Compiler checks that no case is missed
Quick recap
Let us bring it all together in plain words:
- A union type says "this value is exactly one of a fixed list of types."
- You declare it with
public union Name(A, B, C);. - You assign case values directly — the compiler converts them for you.
- You read it with a
switch, and the compiler forces you to handle every case (nodefaultneeded). - It is honest in the method signature and safe at compile time — better than exceptions,
null, hand-written results, or OneOf for most "one of these" situations. - It needs .NET 11 / C# 15 and is still in preview.
Union types fix a gap that bothered C# developers for years. Now a method can finally tell the whole truth about what it returns, and the compiler quietly makes sure you never forget a case. For modelling results, options, and "one of these" outcomes, that is a big, friendly step forward.
References and further reading
Want to go deeper? These are the best sources to learn more, starting with the official ones.
Official Microsoft sources
- Explore union types in C# 15 — .NET Blog — the official announcement, with the syntax and design reasoning straight from the C# team.
- Unions — C# feature specification (Microsoft Learn) — the full, formal proposal for how unions work under the hood.
- Pattern matching in C# (Microsoft Learn) — the
switchand pattern-matching foundation that makes unions so pleasant to read. - What's new in C# 14 (Microsoft Learn) — context on the language version just before unions arrived.
Popular community articles
- C# 15 Unions — NDepend Blog — a respected deep dive comparing the design with F# and other languages.
- C# 15 Union Types ship in .NET 11 Preview 2 — Start Debugging — a hands-on look at trying the preview today.
- OneOf library (GitHub) — the popular library that filled this gap for years; useful for understanding what native unions replace.
Related Posts
Functional Programming in C#: The Practical Parts You Will Actually Use
A warm, beginner-friendly guide to functional programming in C#: records, immutability, pattern matching, switch expressions, pure functions, and LINQ.
How to Write Elegant Code With C# Switch Expressions
Learn C# switch expressions the easy way. Master pattern matching with type, relational, property, and list patterns using simple examples, diagrams, and tables.
The Result Pattern in .NET: Error Handling Without Exceptions
Learn the Result pattern in .NET for clean, explicit error handling. Replace hidden exceptions with type-safe return values using simple examples, railway-oriented diagrams, code, and clear advice on when to use it.
Problem Details for ASP.NET Core APIs: A Beginner's Guide
Learn Problem Details in ASP.NET Core step by step. Give your API one clean error format with RFC 9457, AddProblemDetails, and IExceptionHandler in .NET 10.
Global Error Handling in ASP.NET Core 8 (Beginner Guide)
Learn global error handling in ASP.NET Core 8 with IExceptionHandler, ProblemDetails, and UseExceptionHandler, explained with simple diagrams and clear code.
Global Error Handling in ASP.NET Core: From Middleware to Modern Handlers
Learn global error handling in ASP.NET Core step by step: from try-catch middleware to IExceptionHandler and Problem Details, with simple diagrams and clear code.