Return a C# 15 union type instead of a Result class

Two panels side by side. On the left, TypeScript: type Result = Ok | NotFound, with a switch on result.kind under it. On the right, C#: union Result(Ok, NotFound);, with a switch expression that matches on the types Ok and NotFound under it. A footer line says the C# switch needs no default arm and a missing case is warning CS8509.
COMPANION REPO · LIVE FROM GITHUB

In the Angular half of an app, type Result = Ok | NotFound | Invalid is something you write without thinking. The C# half never had that. It got a Result<T, TError> class, a base record with a few subclasses, or a library like OneOf. C# 15 adds union types. They ship with .NET 11, which is due in November. I tried them this week on RC1, on a small book lookup.

For a service method that returns one of a few outcomes, I'd use a union in new code. It reads better than a hand-written Result class, and the compiler checks the switch. I'd keep it out of JSON and API contracts for now. RC1 writes it with no case marker and couldn't read my union back.

Declaring one

The lookup has three outcomes: the book, not found, or an invalid ISBN. Each outcome is its own record, and the union lists them. Look at the return statements: they return the records directly, with no wrapping.

public record Book(string Isbn, string Title);
public record NotFound(string Isbn);
public record Invalid(string Reason);

public union LookupResult(Book, NotFound, Invalid);

public LookupResult Find(string isbn)
{
    if (isbn.Length != 13 || !isbn.All(char.IsAsciiDigit))
        return new Invalid("An ISBN has 13 digits.");

    return _books.TryGetValue(isbn, out var book) ? book : new NotFound(isbn);
}

I expected to need <LangVersion>preview</LangVersion>, since unions needed it all through the previews, but RC1 doesn't. A plain net11.0 project compiles this, and the RC1 C# notes say C# 15 is now the default. The conditional on the last line needs no cast either, even though Book and NotFound have nothing in common, because the return type is enough.

The switch is where it pays off

Here's the same lookup consumed both ways. The Result class needs two steps, success or failure and then which failure, where the union needs one switch.

// Before: success or failure, then which failure.
string DescribeOld(BookService books, string isbn) => books.FindOld(isbn).Match(
    book => $"Found {book.Title}",
    error => error switch
    {
        BookNotFound nf => $"No book {nf.Isbn}",
        InvalidIsbn bad => bad.Reason,
        _ => throw new UnreachableException(),
    });

// After: one switch, and no discard arm.
string Describe(BookService books, string isbn) => books.Find(isbn) switch
{
    Book book => $"Found {book.Title}",
    NotFound nf => $"No book {nf.Isbn}",
    Invalid bad => bad.Reason,
};

That _ arm in the old version matters more than it looks. The error side is an abstract record, and anyone can add a subclass, so the compiler wants a discard arm. Once it's there, a new error type falls into it, and nothing tells you until it throws. The union is closed: the compiler knows all three cases. So I deleted the Invalid arm in a separate project to see what happens:

Describe.cs(8,66): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Books.Invalid' is not covered.

It names the missing case. It's the same CS8509 you get on an enum, so it's only a warning, and a warning is easy to miss in a long build log. I'd add <WarningsAsErrors>CS8509</WarningsAsErrors> to any project that uses unions, and with it set the same switch breaks the build.

A VALUE FINDS ITS ARM · THEN THE UNION GROWS
THE UNION
union LookupResult(Book, NotFound, Invalid);
new NotFound(isbn)
THE SWITCH
result switch { Book book => $"Found {book.Title}", NotFound nf => $"No book {nf.Isbn}", };
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Books.Invalid' is not covered.
no default arm is needed, because the compiler knows the full list. Add a case and the switch above warns until it gets an arm.

One gap: only the switch expression is checked. A switch statement with the same missing case compiles with no warning at all.

What doesn't carry over from TypeScript

The cases are types. You can't write union Status("ok", "failed") the way you'd write a union of string literals in TypeScript. Every outcome needs a declared type first, and the switch matches on the type, so there's no kind field to check.

The bigger difference is what a union is. The compiler turns it into a struct that keeps the case in one object property called Value, with a constructor per case. A value-type case like int gets boxed on the way in. My records are classes, so nothing gets boxed here.

Being a struct, it also has a default value, and default(LookupResult) holds null. I passed it to a switch with all three arms and got no warning at compile time. At run time it threw a SwitchExpressionException: "Non-exhaustive switch expression failed to match its input."

The language reference says a null arm is needed when Value may be null. My guess is the compiler sees three non-nullable records and decides it can't be. I don't know if that's a gap in RC1 or the design.

With strict null checks on, a TypeScript Result can't be "nothing" unless you add | undefined. A C# union always can, so where one sits in a field or an array, I'd add a null => arm.

JSON and minimal APIs

The RC1 release notes mention union support in System.Text.Json, so I expected a round trip to work. Writing does. Each case is written as itself: a NotFound becomes {"isbn":"9789999999999"}, with no marker saying which case it was.

Reading it back is where it stopped. A plain deserialize call throws a JsonException: "JSON value type 'Object' is ambiguous for union type 'Books.LookupResult'". The reader picks the case from the kind of JSON token, so a union of int and string round-trips fine. Two object cases don't.

The RC1 library notes show a structural classifier that picks the case by property names. It refused my union outright. The not-found record has only an ISBN, and a book has one too, so the error says NotFound "can never be selected uniquely". That's a very common shape for a not-found result, and the classifier rejects it.

A minimal API endpoint that returns the union as it is sends 200 for all three cases. A missing book comes back as a 200 with an ISBN in the body. So I keep the switch at the edge and let each case pick its status code:

app.MapGet("/books/{isbn}", (string isbn, BookService books) => books.Find(isbn) switch
{
    Book book => Results.Ok(book),
    NotFound => Results.NotFound(),
    Invalid bad => Results.BadRequest(bad.Reason),
});

That gives 200, 404 and 400. If a set of outcomes really has to travel as JSON, C# 15 also adds closed hierarchies: public closed record Outcome; with subclasses in the same assembly. The switch over it needs no discard arm either. Put [JsonPolymorphic] on the base and a [JsonDerivedType] on each subclass, and it reads back what it writes. The marker looks like {"$type":"missing","isbn":"9789999999999"}.

Where I'd use it

A union fits a closed set of outcomes that you own and that stays inside your code: a service result, a parser's output, a command handler's reply. If the cases share data, or other assemblies need to add cases, a class hierarchy still fits better. I wouldn't migrate existing Result or OneOf code just for this. The place to start is the next service method that returns more than one outcome, once .NET 11 ships.