Union Types in C# 15

Before diving into the ApiResult<T> monad, it helps to understand the building block it relies on: C# 15 union types.


What Is a Union Type?

A union type is a type that holds exactly one of several named cases at a time. No inheritance, no wrapper objects, no object boxing — just a closed set of possibilities that the compiler knows about at every switch site.

This is sometimes called a discriminated union (the term used in F#) or an algebraic data type (Haskell/Rust). C# 15 brings this as a first-class language feature.


C# 15 Syntax

public union IntOrBool(int i, bool b);

That single line declares a type that can be either an int or a bool. Each member (i, b) is a case — its name is used in pattern matching and conversions.

You can add methods to the body just like a regular type:

public union IntOrBool(int i, bool b)
{
    public readonly bool AsBool() => this switch
    {
        int i  => i != 0,
        bool b => b,
        null   => throw new UnreachableException()
    };

    public readonly int AsInt() => this switch
    {
        int i  => i,
        bool b => b ? 1 : 0,
        null   => throw new UnreachableException()
    };

    public override string ToString() => this switch
    {
        int i  => $"Integer: {i}",
        bool b => $"bool: {b}",
        null   => throw new UnreachableException()
    };
}

What Is New in C# 15 Here?

Feature Detail
union keyword Declares a closed discriminated union type
Primary-parameter syntax (int i, bool b) lists the cases inline
Implicit conversions Assigning an int or bool directly to IntOrBool just works
Exhaustive pattern matching switch on a union must cover every declared case
Reassignment across cases The same variable can hold a different case after reassignment
null arm The compiler may emit a null arm; guard with UnreachableException
Method bodies Full method and property support inside the union body

Union types are value-type-like in semantics — they are closed (no external subclassing) and exhaustive (every case is known at compile time).


Usage — Reassignment Across Cases

One of the most practical aspects of union types is that a single variable can be reassigned to hold a different case. The variable keeps the declared union type; only the runtime case changes:

IntOrBool intOrBool = 42;   // holds an int case
Console.WriteLine(intOrBool);           // "Integer: 42"
Console.WriteLine(intOrBool.AsBool());  // false  (0 == false)
Console.WriteLine(intOrBool.AsInt());   // 42

intOrBool = true;           // reassigned — now holds a bool case
Console.WriteLine(intOrBool);           // "bool: True"
Console.WriteLine(intOrBool.AsBool());  // true
Console.WriteLine(intOrBool.AsInt());   // 1

No cast, no new wrapper, no type mismatch — the implicit conversion handles it. This is what makes union types ergonomic compared to class hierarchies or OneOf<> libraries.


Pattern Matching

Use a switch expression to branch on the current case. The compiler enforces that all cases are handled:

string Describe(IntOrBool value) => value switch
{
    int i  => $"It's an integer: {i}",
    bool b => $"It's a boolean: {b}",
    null   => throw new UnreachableException()
};

If you drop a case the compiler will warn (or error, depending on settings): exhaustiveness is checked statically.


Source

The IntOrBool example is from the UnionTypesDemo1 repository.


Why This Matters for Monads

The ApiResult<T> monad you’ll see next relies on exactly this mechanism — a union type with three cases (Success<T>, HttpError, TransportError) — to represent every possible outcome of an HTTP call in a single, safe, pattern-matchable type.