Skip to main content
SEMastery
.NET Coreintermediate

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.

14 min readUpdated November 21, 2025

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 famous NullReferenceException).
  • Use a bool TryGet(...) with an out parameter (clumsy to read).
  • Build a custom Result class 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.

Figure 1: One method, two possible outcomes. Before unions, the return type could not express this honestly.

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 Pet can 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 works

If 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 Pet

The 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 Rabbit

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

Figure 2: With a union, the compiler walks every switch and checks that no case is missed.

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.

ApproachTells the truth in the signature?Forces you to handle all cases?Needs a library?
Throw an exceptionNoNoNo
Return nullNoNo (easy to forget)No
bool TryGet + outPartlyNoNo
Hand-written Result classYesNo (you must remember)No
OneOf libraryYesAt runtime onlyYes
C# 15 unionYesAt compile timeNo

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 User should just return a User.
  • 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:

  1. Install the .NET 11 preview SDK (Preview 2 or later).
  2. Target net11.0 in your project.
  3. 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 caseGood fit for a union?
A result that is a value or an errorYes
"Found" or "not found" lookupsYes
A payment that is card or UPI or bank transferYes
An open-ended list of many unrelated typesNo — too broad
A value that is always exactly one typeNo — just use that type

And here is how to decide, step by step:

Should this be a union?

A value
One of a few types?
Set is closed?
Use a union

Steps

1

Look at the value

What shapes can it really take?

2

A few types?

2 to 5 distinct shapes is the sweet spot

3

Closed set?

No new types appear at runtime

4

Union!

Declare union Name(A, B, C) and match on it

Use a union only when a value is exactly one of a small, known, closed set of shapes.

Under the hood, a union stores its value in a small wrapper. Picturing this helps you understand the boxing note from earlier:

Figure: A union value wraps exactly one case inside a single small struct, which the compiler checks for you.

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

Declare
Assign
Match
Handle

Steps

1

Declare

Define the closed set: union Pet(Cat, Dog, Bird)

2

Assign

Put in exactly one case — converted for you

3

Match

Use a switch to read the value

4

Handle

Compiler checks that no case is missed

A union value moves left to right: you declare the closed set, assign one case, match on it, and the compiler makes sure every case is handled.

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 (no default needed).
  • 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

Popular community articles

Related Posts