C# 15 Unions: Unions are finally in .NET

After many years of workarounds, design discussions and library-level substitutes, unions are finally becoming a first-class part of C#. The proposal in the C# language repository is no longer a distant idea. With the feature now appearing in the .NET 11 preview train as an early C# 15 capability, the direction is concrete enough to discuss in practical terms.

This matters because unions solve a modeling problem that has existed in C# for a very long time: a value often has exactly one of several valid shapes, but the type system has not had a native way to express that closed set. The result has usually been some combination of nullable values, exception-driven control flow, boolean flags and hand-written result wrappers.

Unions change that. At the same time, there is no reason to wait for C# 15 before writing union-oriented code. Unio already brings that style of modeling to .NET 8, .NET 9 and .NET 10 with very low overhead and code that starts with Unio today is already close to the shape that native unions will want tomorrow.

What the proposal actually adds

The important detail is that the proposal is broader than a single syntax form. It defines a union model for the language and then offers a concise declaration syntax on top of that model.

At a high level, the proposal introduces four capabilities that matter in day-to-day code:

  • Implicit conversions from case types into the union type
  • Pattern matching that unwraps the union contents automatically
  • Exhaustiveness checking in switch expressions when all case types are covered
  • Improved nullability flow for union contents

The headline syntax is straightforward:

1public union Pet(Cat, Dog, Bird);

That declaration means Pet can hold exactly one of those case types. The compiler then allows direct construction through the case values and treats pattern matching over Pet as operating on a closed set.

 1public sealed record Cat(string Name);
 2public sealed record Dog(string Name);
 3public sealed record Bird(string Name);
 4
 5public union Pet(Cat, Dog, Bird);
 6
 7public static string Describe(Pet pet) =>
 8    pet switch
 9    {
10        Cat cat => $"Cat: {cat.Name}",
11        Dog dog => $"Dog: {dog.Name}",
12        Bird bird => $"Bird: {bird.Name}"
13    };

No fallback branch is required here, because the union is understood as exhaustive once all case types have been handled.

Union types and union declarations are not the same thing

One of the most important parts of the proposal is easy to miss on a quick read: C# is not only adding a union ... declaration form. It is also defining what a union type is.

According to the proposal, any class or struct marked with System.Runtime.CompilerServices.UnionAttribute can participate in union behavior, as long as it exposes the required public members. In other words, the language is being taught a pattern, not just a keyword.

That distinction is significant for two reasons.

First, it means unions are designed as a language capability that can extend beyond compiler-generated declarations. Existing or hand-written types can opt into the model.

Second, it leaves room for specialized implementations. The shorthand declaration is intentionally opinionated, but the language model is broader than that shorthand.

The generated union declaration is intentionally opinionated

The proposal is very explicit about the default representation chosen for union declarations. A union declaration lowers to a plain struct with a single object-typed Value property and a generated constructor for each case type.

That has two immediate consequences.

  • It is compact.
  • Value-type cases are boxed when stored through the default declaration form.

This is not an accident. The proposal positions union declarations as the simple, broadly useful default. For many application-level cases, that is the right trade-off. Service results, command outcomes, parsing states and protocol messages are often dominated by clarity and correctness, not by the last few nanoseconds of storage efficiency.

Still, the proposal also acknowledges that this default will not be ideal for every scenario. It explicitly leaves room for custom unions and for non-boxing access patterns where layout and performance matter more than a compact one-field representation.

That nuance is important. Native unions will be a major improvement for mainstream code, but they do not make specialized union libraries obsolete. They mostly replace the need to hand-roll the common case.

Why unions matter in ordinary C# code

The immediate value of unions is not fashion and not syntax. It is the ability to state, in the type system, that an operation has a closed set of valid outcomes.

Consider a service that loads an order. In many codebases, the return path ends up as Order?, an exception on missing data, or a custom wrapper that nobody can inspect without opening the implementation. A union makes the contract visible where it belongs: on the signature.

1public sealed record NotFound;
2public sealed record Unauthorized;
3public sealed record Order(int Id);
4
5public union GetOrderResult(Order, NotFound, Unauthorized);

That signature documents three outcomes and no others. The compiler can then enforce that all three are handled. This is where unions become more than a convenience feature. They move correctness checks earlier, reduce hidden control flow and make result modeling much harder to misuse.

This is also the reason unions fit naturally beside other upcoming C# work such as closed hierarchies and case declarations. The language is moving toward a stronger notion of closed sets and exhaustiveness and unions are one of the clearest expressions of that direction.

Unio already brings this model to .NET 8 and newer

The practical question is whether it makes sense to adopt the union style now or wait until the C# 15 syntax is fully available. In many cases, waiting is unnecessary.

Unio already offers discriminated unions today on net8.0, net9.0 and net10.0. More importantly, it does so with a design that is intentionally lightweight at runtime:

  • A readonly struct core type
  • Typed generic fields instead of storing everything through object
  • A one-byte discriminator
  • Exhaustive Match and Switch APIs
  • A source generator for named union types

That design keeps the runtime overhead very small and avoids the boxing cost that object-based union storage introduces for value types. For applications that care about hot-path efficiency, that difference is not academic.

The basic usage is already close to the native mental model:

 1using Unio;
 2
 3[GenerateUnio]
 4public sealed partial class GetOrderResult : UnioBase<Order, NotFound, Unauthorized>;
 5
 6public static string Describe(GetOrderResult result) =>
 7    result.Match(
 8        static Order order => $"Order {order.Id}",
 9        static NotFound _ => "Not found",
10        static Unauthorized _ => "Unauthorized");

This is not just a stopgap. It already captures the essential architectural choice that unions encourage: result types are explicit, closed and compiler-guided.

Starting with Unio keeps the eventual migration small

This is the most practical part of the story.

If code already models outcomes with explicit case types, returns named union result types and consumes them through exhaustive matching, then the eventual move to native unions in C# 15 is largely a surface migration.

The domain types stay the same:

1public sealed record NotFound;
2public sealed record Unauthorized;
3public sealed record Order(int Id);

The Unio declaration used today:

1using Unio;
2
3[GenerateUnio]
4public sealed partial class GetOrderResult : UnioBase<Order, NotFound, Unauthorized>;

can later move to a native union declaration:

1public union GetOrderResult(Order, NotFound, Unauthorized);

The consuming code then shifts from Match or Switch calls to native switch expressions and pattern matching, but the actual business modeling does not need to be reinvented.

In other words, starting with Unio today does not lead into a dead end. It trains a codebase toward the exact modeling discipline that native unions want: explicit case types, closed outcomes and exhaustive handling.

There is one important caveat and it should be stated plainly. Minimal migration effort does not automatically mean identical runtime behavior in every performance-sensitive path. Unio’s core type stores values in typed fields and avoids boxing. The default C# 15 union declaration is object-based and intentionally compact. For application service results, this difference will often be irrelevant. For performance-critical, value-heavy unions, replacement should be benchmarked rather than assumed.

That caveat does not weaken the overall conclusion. It simply draws the correct line between source-level migration cost and runtime storage characteristics.

A good moment for result types in C#

For a long time, unions in C# were discussed as something other languages had and C# lacked. That is no longer the right framing. The interesting part now is not whether C# should have unions, but how codebases can start taking advantage of the model immediately.

The C# 15 proposal finally gives the language a first-class answer for closed result shapes and exhaustive handling. Unio already provides a practical answer for current production code on modern .NET versions, with minimal overhead and a migration path that is mostly about syntax rather than architecture.

That combination is unusually strong. The language direction is now clear and the tooling to write union-oriented code today already exists.


Let's Work Together

Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.

New Platforms
Modernization
Training & Consulting

Comments

Twitter Facebook LinkedIn WhatsApp