C# has had a slightly awkward gap in its type system for a long time. Sometimes a value isn’t one specific type. It can be one of a small number of known types, and you want the compiler to understand exactly what those possibilities are.
You can obviously model this today. You can use an abstract base class, an interface, a custom Result<T> type, a library like OneOf, or if you really hate yourself, object. But none of those are quite the same thing as telling the compiler: this value can only ever be one of these types.
C# 15 finally gives us a native way of expressing that with union types.
The syntax is very small:
public record Cat(string Name);public record Dog(string Name);public record Bird(string Name);public union Pet(Cat, Dog, Bird);
Pet is now its own type, but it can contain a Cat, Dog, or Bird. Nothing else.
Because each case has an implicit conversion to the union, you don’t need to manually wrap values either:
Pet pet = new Dog("Rex");Pet anotherPet = new Cat("Garfield");
Trying this doesn’t compile:
Pet pet = "Definitely a dog";
That alone is useful, but it’s not the most interesting part of the feature.
Affiliate promo
If you love learning new stuff and want to support me, consider buying a course from Dometrain using this link: Browse courses – Dometrain. Thank you!
The Compiler Actually Understands the Cases
The biggest difference between a union and throwing a few types behind object is that the compiler knows every possible value the union can contain.
That means pattern matching becomes exhaustive:
string GetName(Pet pet){ return pet switch { Cat cat => cat.Name, Dog dog => dog.Name, Bird bird => bird.Name };}
Notice what’s missing.
There is no:
_ => throw new InvalidOperationException()
The compiler knows that Cat, Dog, and Bird are all the possible cases, so there’s nothing else to handle.
This also means that changing the union changes every place consuming it. Imagine we add another case:
public record Fish(string Name);public union Pet(Cat, Dog, Bird, Fish);
Any exhaustive switch that hasn’t considered Fish can now produce a compiler warning.
That’s exactly what I want from this kind of feature. If my domain changes, I don’t want some forgotten default branch silently swallowing the new case. I want the compiler pointing at the places that need to change.
A More Useful Example
Pets are great for explaining the syntax, but this becomes much more interesting when you start looking at results coming back from application code.
Imagine this method:
public async Task<User?> GetUserAsync(Guid id){ // ...}
null tells us that something didn’t work, but it tells us almost nothing about what actually happened.
Maybe the user didn’t exist. Maybe the request was invalid. Maybe the caller isn’t allowed to access that user.
You could start returning some kind of result hierarchy:
public abstract record GetUserResult;public record UserFound(User User) : GetUserResult;public record UserNotFound(Guid Id) : GetUserResult;public record InvalidRequest(string Message) : GetUserResult;
That works, but we’ve created an inheritance hierarchy mostly because we needed a common return type.
With a union, the individual types don’t need to know anything about each other:
public record UserFound(User User);public record UserNotFound(Guid Id);public record InvalidRequest(string Message);public union GetUserResult( UserFound, UserNotFound, InvalidRequest);
Now the method can simply return whichever case applies:
public async Task<GetUserResult> GetUserAsync(Guid id){ if (id == Guid.Empty) { return new InvalidRequest("User ID cannot be empty."); } var user = await database.Users.FindAsync(id); if (user is null) { return new UserNotFound(id); } return new UserFound(user);}
And consuming the result is straightforward:
var result = await GetUserAsync(id);return result switch{ UserFound found => Results.Ok(found.User), UserNotFound notFound => Results.NotFound(), InvalidRequest invalid => Results.BadRequest(invalid.Message)};
There is no common interface. There is no base class. The result types can remain completely independent.
More importantly, there isn’t a meaningless fallback case either.
This Is Where Unions Beat object
Technically, we could have written this ten years ago:
public async Task<object> GetUserAsync(Guid id)
Then return whatever we want.
And that’s exactly the problem.
This works:
return new UserFound(user);
but so does this:
return 42;
and this:
return new Random();
The method signature communicates almost nothing.
A union puts that information directly into the type:
public union GetUserResult( UserFound, UserNotFound, InvalidRequest);
Someone reading the method doesn’t need documentation explaining every possible result. The type already tells them.
That’s probably the part of unions I like the most. They let us model a constraint that already existed in our heads but previously wasn’t properly represented by the type system.
So What About OneOf?
If you’ve been using OneOf, this is probably looking very familiar.
You might have something like:
OneOf<UserFound, UserNotFound, InvalidRequest>
and conceptually that solves a very similar problem.
C# unions don’t suddenly make libraries like OneOf useless. Mature result libraries often provide additional APIs around matching, mapping, chaining operations and other functional-style operations that a native union doesn’t automatically give you.
What changes is that the basic concept is now understood by the language itself.
Instead of:
OneOf<UserFound, UserNotFound, InvalidRequest>
you can give the concept a proper domain name:
public union GetUserResult( UserFound, UserNotFound, InvalidRequest);
and the C# compiler understands the relationship between those types, including exhaustive pattern matching.
For simple cases where all I need is “this can be A, B or C”, I would much rather have this represented directly in C# than introduce a dependency just to express the type.
Unions Aren’t Inheritance
There’s an important distinction here because C# 15 is also introducing closed hierarchies.
A union doesn’t mean the cases inherit from the union:
public union Pet(Cat, Dog);
Cat isn’t a subclass of Pet.
The union is effectively a container that can hold one of those types.
This is why unrelated types can be combined:
public union StringOrNumber(string, int);
There is obviously no useful inheritance relationship between string and int, but they can still be cases of the same union.
This is particularly useful when you’re modelling an existing contract that genuinely accepts values of completely different shapes.
ASP.NET Core Supports Them Too
One place where this gets surprisingly practical is ASP.NET Core.
Imagine an API contract where a value can either be an absolute number or a percentage:
public union MaxUnavailable(int, string);
Your endpoint can return either:
app.MapGet("/max-unavailable", () =>{ MaxUnavailable value = 2; return value;});
or:
MaxUnavailable value = "25%";
System.Text.Json understands unions in .NET 11. It serializes the active value rather than adding some special union wrapper.
So:
MaxUnavailable value = 2;
becomes:
2
while:
MaxUnavailable value = "25%";
becomes:
"25%"
OpenAPI understands this as well and represents the union using anyOf.
There is an important catch when deserializing unions. If two cases have JSON that looks exactly the same structurally, the serializer needs some way to decide which case to create. A union between int and string is obvious because the JSON types are different. A union between two different classes represented as JSON objects can be ambiguous.
That’s one of those details that makes unions great for some API contracts but not automatically the correct choice for every polymorphic model.
If you control all the types and they’re genuinely part of the same hierarchy, a closed hierarchy can still make more sense.
There Is One Performance Detail You Should Know
The convenient union syntax is deliberately opinionated.
When you write:
public union Something(string, int);
the compiler effectively generates a struct that stores its current value through an object? reference.
That means value types are boxed.
For normal application code this probably won’t matter, but it’s worth knowing before replacing some extremely hot code path with unions containing value types.
C# 15 also allows custom types to participate in union behavior through the [Union] attribute. Custom implementations can provide a non-boxing access pattern using HasValue and TryGetValue methods.
So the language feature isn’t inherently tied to boxing, but the convenient union declaration uses that simple storage model.
I wouldn’t worry about this until profiling tells me to worry about it, but I also wouldn’t pretend the cost doesn’t exist.
There’s Also a Slightly Weird default Case
Because the generated union is a struct storing an object?, this is possible:
Pet pet = default;
The underlying value is now null.
So you can encounter code like:
var result = pet switch{ Cat cat => cat.Name, Dog dog => dog.Name, Bird bird => bird.Name, null => "No pet"};
C#’s nullable flow analysis helps here, so you don’t necessarily need a null case every time you use a union. But the fact that default(Pet) exists is something to keep in mind.
It’s one of those places where you can see the compromises involved in fitting union types into C# rather than designing a language around them from day one.
Should You Use Them?
I think union types are one of the more useful additions coming in C# 15, mostly because they solve a problem C# developers have already been solving manually for years.
They aren’t going to replace inheritance. They aren’t going to replace every Result<T> implementation. And they don’t make libraries like OneOf instantly obsolete.
But when a value genuinely means one of these specific types and nothing else, we can finally express exactly that.
public union PaymentResult( PaymentSucceeded, PaymentDeclined, PaymentFailed);
That communicates far more than:
object
and requires far less ceremony than creating an inheritance hierarchy purely to give three unrelated results a common parent.
Even better, the compiler understands the contract and can tell us when we’ve forgotten to handle one of its cases.
At the time of writing, C# 15 is still a preview feature and ships with .NET 11 in November 2026. To try unions today, target .NET 11 and enable the preview language version:
<PropertyGroup> <TargetFramework>net11.0</TargetFramework> <LangVersion>preview</LangVersion></PropertyGroup>
This is one of those language features that looks tiny when you first see the syntax:
public union Result(Success, Error);
But the interesting part isn’t the keyword.
It’s finally being able to tell the C# compiler exactly what values are allowed to exist.
Affiliate promo
If you love learning new stuff and want to support me, consider buying a course from Dometrain using this link: Browse courses – Dometrain. Thank you!

Leave a comment