Every C# codebase I’ve worked on has its own way of saying “this method returns one of a few things”. An exception for the failures. A Result<T> with an Error string. An abstract record with a few subclasses and a switch that ends in _ => throw new UnreachableException(). The OneOf package. They all work, and they all share a weakness: when somebody adds a new outcome, nothing tells the callers.
C# 15, which ships with .NET 11 in November, fixes that with union types: a type that holds exactly one of a closed list of types, and a compiler that knows the list. This article tries them on a realistic example, with .NET 11 RC2 (SDK 11.0.100-rc.2), and covers what changes, what the compiler catches and where the edges are. No preview flags were needed: on net11.0, C# 15 is the default.
The problem with “one of these”
A checkout places an order. It can succeed, the item can be out of stock, or the payment can be declined. None of the failures is exceptional; the caller has to handle all three. Before C# 15 a good version of that looked like this:
public abstract record PlaceOrderResult;
public record Order(string Id, decimal Total) : PlaceOrderResult;
public record OutOfStock(string Sku, int Available) : PlaceOrderResult;
public record PaymentDeclined(string Reason) : PlaceOrderResult;
And every caller ended its switch like this:
_ => throw new UnreachableException(),
The compiler insists on that last arm. An abstract record can gain subclasses anywhere, so a switch over three of them isn’t exhaustive, and leaving the arm out gives warning CS8509 with the pattern '_' as the example. Once the arm is there, the warning is gone for good, including on the day a fourth outcome is added. The new case compiles, ships, and throws in production.
A union
In C# 15 the result is declared as what it is:
public record Order(string Id, decimal Total);
public record OutOfStock(string Sku, int Available);
public record PaymentDeclined(string Reason);
public union PlaceOrderResult(Order, OutOfStock, PaymentDeclined);
The case types don’t share a base class and don’t need to know about the union. Each one converts to it implicitly, so the method simply returns whichever it has:
public PlaceOrderResult Place(string sku, int quantity, decimal price, bool cardOk)
{
var available = stock.GetValueOrDefault(sku);
if (available < quantity) return new OutOfStock(sku, available);
if (!cardOk) return new PaymentDeclined("Card refused by the issuer");
return new Order($"A-{Random.Shared.Next(1000, 9999)}", quantity * price);
}
The caller matches on the case types, and there’s no discard arm:
static string Message(PlaceOrderResult result) => result switch
{
Order o => $"Order {o.Id} placed: €{o.Total:0.00}",
OutOfStock s => $"Only {s.Available} of {s.Sku} left",
PaymentDeclined p => $"Payment declined: {p.Reason}",
};
Order A-2047 placed: €25.00
Only 3 of mug left
Payment declined: Card refused by the issuer
Adding a case
This is the part that pays for the feature. A new rule rejects some delivery addresses, so the union gets a fourth case:
public record AddressRejected(string Field);
public union PlaceOrderResult(Order, OutOfStock, PaymentDeclined, AddressRejected);
Every switch that doesn’t handle it now gets the warning, naming the missing case:
Program.cs(24,62): warning CS8509: The switch expression does not handle all possible
values of its input type (it is not exhaustive). For example, the pattern
'AddressRejected' is not covered.
It’s a warning, though, not an error. To make it stop the build, promote it in the project file. This one line is what turns unions from a nicer syntax into a safety net:
<WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors>
With it, adding a case is a compile error at every place that needs a decision, which is exactly the to-do list you want. (The check covers the case types, not an empty union: more on that below.)
Generic unions and members
A union can be generic and can have members, which covers the classic Result<T>:
public record NotFound(string Id);
public record Invalid(string Field, string Message);
public union Result<T>(T, NotFound, Invalid)
{
public bool IsSuccess => Value is T;
}
Value is the one property every union declaration has: the case it holds, as an object?. A parser can then return any of the three:
static Result<int> Parse(string s) =>
s == "" ? new NotFound("empty")
: int.TryParse(s, out var n) ? n
: new Invalid("s", "not a number");
Advertisement
Closed hierarchies
Sometimes the cases should share a base: they have common data, or a common method. For those, C# 15 adds closed classes and records:
public closed record Shape;
public record Circle(double R) : Shape;
public record Square(double Side) : Shape;
static double Area(Shape s) => s switch
{
Circle c => Math.PI * c.R * c.R,
Square q => q.Side * q.Side,
};
A closed type is implicitly abstract (marking it abstract as well is an error, CS9384), and its direct subclasses must live in the same assembly. Another project that tries to add a Triangle gets CS9382, “cannot use a closed type ‘Shape’ from another assembly as a base type”. Since the compiler knows every direct subclass, the switch above is exhaustive with no discard. The subclasses themselves aren’t closed: a BigSquare : Square in the same assembly is still covered by the Square arm.
So which one? Use a union when the cases are unrelated types, especially ones you don’t own (int, string, a DTO from another library), or when the grouping only makes sense for one method’s result. Use a closed hierarchy when the cases are a family that shares data or behaviour and lives in your code.
What I’d watch out for
Value-type cases are boxed. In the RC2 build a union is a struct with an object inside. Putting an int into a union IntOrText(int, string) allocated 24 bytes every time I measured it. That’s irrelevant for an API result and relevant in a hot loop, where a hand-written struct still wins, or a custom union type that implements the non-boxing HasValue/TryGetValue pattern the compiler also understands.
A default union holds nothing, and the compiler doesn’t notice. default(Result<int>) has a null Value and matches none of the cases. A switch with an arm for every case compiles without a warning and throws SwitchExpressionException when handed one. A union field you never assigned is the usual way to get there, so where a default is possible, add a null => … arm (the null pattern tests Value is null).
Switch statements aren’t checked. Exhaustiveness is checked for switch expressions. A switch statement that handles two cases of three compiles without a word, and the third is silently skipped. Prefer the expression form, or end the statement with a default that throws.
JSON needs a hint for objects. System.Text.Json in .NET 11 understands unions. Serializing writes the case’s value as it is: {"Name":"Rex","GoodBoy":true} for a Dog in a union Pet(Cat, Dog), with no type marker. Reading back works when the JSON kind decides the case (a number for int, a string for string). When two cases are both objects, it throws: “JSON value type ‘Object’ is ambiguous for union type ‘Pet’ because multiple case types can use this value type…”. The built-in structural classifier picks the case by its property names:
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Pet(Cat, Dog);
With it, {"Name":"Tom","Lives":9} comes back as a Cat and {"Name":"Rex","GoodBoy":true} as a Dog, but {"Name":"Who"} fits neither and throws, and so does an object that fits both. For a public API, a type discriminator is still the more robust contract.
It’s new. Unions arrived during the .NET 11 previews and everything here is from a release candidate, so check the final release notes in November; libraries will also take a while to agree on conventions. For results inside your own application, there’s no reason to wait.
What I’d keep in mind
- Return a union when the caller must handle every outcome. No exceptions for flow control, no
Errorstrings. - Turn CS8509 into an error. Without it, a new case is only a warning.
- Prefer switch expressions. Statements aren’t checked for exhaustiveness.
- Closed records for families, unions for unrelated types.
- Mind boxing in hot paths, and
defaultin fields: the compiler won’t catch an empty union. - Give object-shaped cases a JSON classifier before they cross a wire.
The best thing about unions is what they remove: the _ => throw arm that nobody tests, sitting in every switch, waiting for the case that someone adds next year. They don’t make a switch unable to throw (an empty union still does), but they make the common mistake a compile error.


