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 Error strings.
  • 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 default in 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.