Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part III: The Union Pattern

"Make illegal states unrepresentable." -- Yaron Minsky, Jane Street, 2011

C# does not have discriminated unions. This is not a controversial statement. It is a fact that the language team has acknowledged repeatedly, that has appeared on the C# feature proposals list since at least 2017, and that remains unresolved as of C# 13. Every few years a new proposal surfaces -- "closed type hierarchies," "enum classes," "discriminated unions" -- and every few years it gets deferred in favor of more immediately pressing features. The proposals are well-written. The motivation is clear. The feature does not ship.

Meanwhile, developers need to represent closed sets of types every day. An API response is either a success payload, a validation error, or a not-found sentinel. A payment method is either a credit card, a bank transfer, or a cryptocurrency wallet. A parsed token is either an integer literal, a string literal, or an identifier. These are not polymorphic hierarchies where new variants can be added by downstream code. They are closed, finite, exhaustive sets where the consumer must handle every case.

C# developers have been working around the absence of unions for decades. Some use inheritance hierarchies with abstract base classes and derived records. Some use enums with associated data stored in parallel fields. Some use object and cast. Some use marker interfaces. Some use the Visitor pattern. All of these approaches have the same fundamental problem: none of them guarantee at compile time that every variant is handled. You can add a new derived record to your hierarchy and the compiler will not tell you that twenty switch expressions across the codebase now have an unhandled case.

FrenchExDev's Union package takes a pragmatic approach. It provides three generic classes -- OneOf<T1, T2>, OneOf<T1, T2, T3>, and OneOf<T1, T2, T3, T4> -- that represent a value that is exactly one of N types. The Match method requires a handler for every variant. If you add a type parameter, every Match call in the codebase stops compiling until you add the new handler. This is not clever. This is not novel. This is what F# has had since 2005. But it works, and it works today, in production C# code, without waiting for the language team.

This chapter covers the complete FrenchExDev.Net.Union package: the internal representation (byte discriminator and object storage), factory methods and implicit conversions, property-based variant checking, exhaustive matching with Match and Switch, safe extraction with TryGet<T>, value equality with IEquatable, conditional compilation for GetHashCode, and the decision framework for choosing between Union, Option, and Result.


The Design Space

Before looking at the API, it is worth understanding the design decisions that shaped it. Every union library for C# makes different tradeoffs, and understanding FrenchExDev's choices requires understanding what the alternatives are and why they were rejected.

Why Not Inheritance Hierarchies?

The most common C# workaround for discriminated unions is an abstract base class with derived types:

public abstract record PaymentMethod;
public sealed record CreditCard(string Number, string Expiry) : PaymentMethod;
public sealed record BankTransfer(string Iban, string Bic) : PaymentMethod;
public sealed record Cryptocurrency(string WalletAddress, string Network) : PaymentMethod;

This looks clean. It uses native C# features. It supports pattern matching with switch expressions. But it has a critical flaw: the set of variants is open.

Anyone can add a new derived type in another assembly:

// In a completely different project
public sealed record CashOnDelivery(string Instructions) : PaymentMethod;

Now every switch expression that matches on PaymentMethod has an unhandled case. The compiler does not warn you. The code compiles. The switch hits the default branch -- or worse, it hits the _ discard pattern that you wrote as a "just in case" fallback. The new variant is silently swallowed.

You can mitigate this by making the base class internal, or by using a private constructor in a nested type hierarchy. But these are conventions, not guarantees. And they are verbose: every new "union" requires a new base class, new derived types, and careful access modifier management.

OneOf<T1, T2, T3> does not have this problem. The set of variants is encoded in the type parameters. You cannot add a fourth variant without changing the type to OneOf<T1, T2, T3, T4>, which changes the Match signature, which breaks every call site. The compiler enforces the closed set.

Why Not F#?

F# has native discriminated unions and they are excellent:

type PaymentMethod =
    | CreditCard of number: string * expiry: string
    | BankTransfer of iban: string * bic: string
    | Cryptocurrency of wallet: string * network: string

If your team uses F#, use F#. FrenchExDev is a C# library for C# teams. It does not attempt to replicate the full power of F#'s type system. It provides a pragmatic subset -- three arity classes, exhaustive matching, value equality -- that covers the vast majority of real-world union use cases in C# codebases. The goal is not language parity with F#. The goal is eliminating the most common classes of bugs caused by the absence of unions in C#.

Why Three Arity Classes?

OneOf<T1, T2>, OneOf<T1, T2, T3>, and OneOf<T1, T2, T3, T4>.

Why not OneOf<T1, ..., T8> like some union libraries provide? Because the API surface degrades with arity. Consider what Match looks like with eight type parameters:

// This is not code you want to write, read, review, or debug
var result = value.Match(
    withT1: t1 => HandleCase1(t1),
    withT2: t2 => HandleCase2(t2),
    withT3: t3 => HandleCase3(t3),
    withT4: t4 => HandleCase4(t4),
    withT5: t5 => HandleCase5(t5),
    withT6: t6 => HandleCase6(t6),
    withT7: t7 => HandleCase7(t7),
    withT8: t8 => HandleCase8(t8)
);

Eight lambdas in a single method call. Eight branches that must be read and understood. Eight potential points of failure in code review. This is not a design improvement over a switch with eight cases -- it is the same thing with worse syntax.

The practical sweet spot is two, three, and four. Two covers the most common case: success or failure, left or right, this or that. Three covers the next most common case: success, client error, or server error. Four covers the rare but legitimate case where you have four distinct alternatives. Beyond four, the union is telling you something: your domain model has too many states crammed into one type, and you should decompose it.

If you genuinely need five or more variants, model them as a record hierarchy with a Visitor pattern, or refactor your domain to reduce the variant count. FrenchExDev does not provide OneOf<T1, ..., T5> because providing it would be an endorsement of designs that need redesigning.

Why Sealed Class, Not Struct?

OneOf<T1, T2> is a sealed class, not a struct. This is a deliberate choice.

Reference semantics. A union holds an object _value field. If OneOf were a struct, every assignment would copy the entire struct, including the boxed reference to the value. Structs are value types, but the internal storage is a reference type anyway (because _value is typed as object). Making the container a struct would give the appearance of value semantics without the reality, because the internal reference would still be shared. This is confusing and would lead to subtle bugs.

Boxing concerns. When T1 or T2 is a value type (like int), the value is boxed when stored in the object _value field. This is a deliberate tradeoff. The alternative would be to use T1 _value1; T2 _value2; and waste storage for the inactive variant, or to use Unsafe.As tricks that sacrifice safety for performance. FrenchExDev chooses correctness over micro-optimization. The union is about type safety, not about avoiding a single allocation. If you are in a hot loop where a single boxing allocation matters, you should not be using a generic union type -- you should be using a specialized struct with explicit fields.

Nullable analysis. Sealed classes work cleanly with C#'s nullable reference type analysis. The compiler can reason about OneOf<string, int>? without ambiguity. With structs, the interaction between Nullable<T> (for nullable value types) and nullable reference types creates edge cases that are difficult to handle correctly.


Anatomy of OneOf<T1, T2>

The full type signature:

public sealed class OneOf<T1, T2> : IEquatable<OneOf<T1, T2>>
    where T1 : notnull where T2 : notnull

Three things are encoded here: sealed for a closed type, IEquatable for value equality, and notnull constraints on both type parameters to prevent null values from entering the union.

The Byte Discriminator

The internal representation is minimal:

private readonly object _value;
private readonly byte _index;

Two fields. _value holds the actual value, boxed into object. _index holds a byte that identifies which variant is active: 0 for T1, 1 for T2.

Why byte? Because a byte can hold values from 0 to 255, which is far more than the maximum four variants we support. A byte is the smallest integer type in .NET. Using int would waste three bytes per union instance for no benefit. Using a bool would be sufficient for OneOf<T1, T2> but would not generalize to three and four variants.

Why object for _value? Because C# does not support type unions at the storage level. There is no way to declare a field that is "either a T1 or a T2" without using object or Unsafe tricks. The object field means that value types (like int, decimal, DateTime) are boxed when stored. This is a known cost, accepted for three reasons. First, unions are used at domain boundaries -- method returns, API responses, command results -- not in tight inner loops. Second, the boxing cost is a single allocation that the GC handles efficiently. Third, the alternative (separate fields per variant) wastes memory for inactive variants and complicates the implementation without eliminating boxing for the active variant's interactions with the rest of the system.

The private constructor ensures that instances can only be created through factory methods:

private OneOf(object value, byte index)
{
    _value = value;
    _index = index;
}

No public constructor. No way to create a OneOf with mismatched _value and _index. The invariant is enforced at creation time.

Factory Methods: From(T1) / From(T2)

The primary way to create a union is through the static From methods:

public static OneOf<T1, T2> From(T1 value) => new(value, 0);
public static OneOf<T1, T2> From(T2 value) => new(value, 1);

Each method wraps the value and sets the correct discriminator index. Usage is straightforward:

OneOf<string, int> fromString = OneOf<string, int>.From("hello");
OneOf<string, int> fromInt = OneOf<string, int>.From(42);

The From methods are the explicit, unambiguous way to create a union. They make the intent clear: "I am creating a union that holds this specific variant." They are verbose -- you must repeat the full type name -- but they are never ambiguous.

Implicit Conversions

For ergonomic usage, OneOf<T1, T2> provides implicit conversion operators:

public static implicit operator OneOf<T1, T2>(T1 value) => From(value);
public static implicit operator OneOf<T1, T2>(T2 value) => From(value);

This means you can write:

OneOf<string, int> x = "hello";   // implicit From(T1)
OneOf<string, int> y = 42;         // implicit From(T2)

Implicit conversions are particularly valuable in method return types, where they eliminate the From ceremony:

public OneOf<User, NotFoundError> FindUser(int id)
{
    var user = _db.Users.FirstOrDefault(u => u.Id == id);
    if (user is not null)
        return user;                  // implicit conversion to OneOf via From(T1)

    return new NotFoundError(id);     // implicit conversion to OneOf via From(T2)
}

Without implicit conversions, every return statement would require OneOf<User, NotFoundError>.From(user) or OneOf<User, NotFoundError>.From(new NotFoundError(id)). The implicit operators reduce noise without reducing safety -- the type system still knows exactly which variant is being returned.

A caveat about ambiguity. Implicit conversions can be ambiguous when T1 and T2 are related by inheritance. For example, OneOf<object, string> has a problem: string is implicitly convertible to object, so OneOf<object, string> x = "hello" is ambiguous -- should it be T1 (object) or T2 (string)? In practice, this does not arise often because the whole point of a union is to distinguish between types that are not related. If T1 is assignable from T2, you probably do not need a union -- you need a single type. But if you encounter this, use the explicit From methods to disambiguate.

Property Access: IsT1, IsT2, AsT1, AsT2

For conditional logic where a full Match is unnecessary, OneOf<T1, T2> exposes boolean properties and typed accessors:

public bool IsT1 => _index == 0;
public bool IsT2 => _index == 1;

These are simple index comparisons. They tell you which variant is active without extracting the value.

The typed accessors provide direct access to the value:

public T1 AsT1 => _index == 0
    ? (T1)_value
    : throw new InvalidOperationException(
        $"Cannot access T1 when the value is T2 ({_value.GetType().Name})");

public T2 AsT2 => _index == 1
    ? (T2)_value
    : throw new InvalidOperationException(
        $"Cannot access T2 when the value is T1 ({_value.GetType().Name})");

Accessing the wrong variant throws InvalidOperationException. This is intentional -- if you bypass the safe Match/Switch API and access AsT1 without checking IsT1, the exception tells you exactly what went wrong.

The Is + As pattern is useful in scenarios where you only care about one variant:

OneOf<Success, Error> result = ProcessRequest();

if (result.IsT1)
{
    var success = result.AsT1;
    _logger.LogInformation("Request succeeded: {Id}", success.Id);
}
else
{
    var error = result.AsT2;
    _logger.LogWarning("Request failed: {Message}", error.Message);
}

However, Match is almost always preferable because it enforces exhaustiveness:

// Better: compiler ensures both cases are handled
result.Match(
    withT1: success => _logger.LogInformation("Succeeded: {Id}", success.Id),
    withT2: error => _logger.LogWarning("Failed: {Message}", error.Message)
);

ToString

OneOf<T1, T2> provides a descriptive string representation:

// Returns "T1(value)" or "T2(value)" depending on which variant is active
public override string ToString() => _index switch
{
    0 => $"T1({_value})",
    1 => $"T2({_value})",
    _ => throw new InvalidOperationException("Invalid index")
};

Examples:

OneOf<string, int> a = "hello";
Console.WriteLine(a);  // "T1(hello)"

OneOf<string, int> b = 42;
Console.WriteLine(b);  // "T2(42)"

This is useful in logging, debugging, and test failure messages. You can see at a glance which variant is active and what value it holds.

Class Diagram

Diagram
Three arities, no base class — each OneOf is a standalone sealed type implementing IEquatable, deliberately avoiding the inheritance hierarchy unions are meant to replace.

The three arity classes share the same internal structure and public API shape. The only difference is the number of type parameters, factory methods, properties, and Match/Switch lambda parameters. They do not inherit from a common base class -- each is a standalone sealed class. This avoids the very inheritance-hierarchy problem that unions are meant to solve.


Exhaustive Matching

The most important methods on OneOf<T1, T2> are Match and Switch. They are the primary way to consume a union value, and they enforce exhaustive handling at compile time.

Match: Returning a Value

public TResult Match<TResult>(
    Func<T1, TResult> withT1,
    Func<T2, TResult> withT2)
{
    return _index switch
    {
        0 => withT1((T1)_value),
        1 => withT2((T2)_value),
        _ => throw new InvalidOperationException("Invalid index")
    };
}

Match takes one function per variant. Every function must return the same type TResult. The compiler enforces that you provide all branches -- if you delete withT2, the code does not compile. If you add a type parameter (upgrading to OneOf<T1, T2, T3>), every Match call breaks until you add withT3.

This is the fundamental guarantee that distinguishes unions from inheritance hierarchies. With a switch on a base class, the compiler does not know the set of derived types. With Match on a OneOf, the compiler knows exactly how many branches are required -- because the count is encoded in the type parameters.

Example: API Response Handling

public record SuccessResponse(OrderDto Order);
public record ValidationError(string[] Errors);
public record NotFoundError(string Resource, string Id);

public OneOf<SuccessResponse, ValidationError, NotFoundError> GetOrder(string orderId)
{
    if (string.IsNullOrWhiteSpace(orderId))
        return new ValidationError(new[] { "Order ID is required" });

    var order = _repository.FindById(orderId);
    if (order is null)
        return new NotFoundError("Order", orderId);

    return new SuccessResponse(_mapper.Map(order));
}

The caller must handle all three cases:

var result = _orderService.GetOrder(orderId);

IActionResult response = result.Match(
    withT1: success => Ok(success.Order),
    withT2: validation => BadRequest(validation.Errors),
    withT3: notFound => NotFound($"{notFound.Resource} {notFound.Id} not found")
);

There is no default branch. There is no _ discard. There is no "I forgot to handle the new error type." Every variant is handled, and the types flow through to the handler lambdas with full IntelliSense and compile-time checking.

Example: Command Result

public record Created(Guid Id);
public record Conflict(string Reason);

public OneOf<Created, Conflict> CreateUser(CreateUserCommand command)
{
    if (_repository.ExistsByEmail(command.Email))
        return new Conflict($"User with email {command.Email} already exists");

    var user = User.Create(command.Name, command.Email);
    _repository.Save(user);
    return new Created(user.Id);
}

The caller:

var result = _userService.CreateUser(command);

string message = result.Match(
    withT1: created => $"User created with ID {created.Id}",
    withT2: conflict => $"Cannot create user: {conflict.Reason}"
);

Example: Parsing

public record IntegerLiteral(int Value);
public record StringLiteral(string Value);
public record Identifier(string Name);

public OneOf<IntegerLiteral, StringLiteral, Identifier> ParseToken(string raw)
{
    if (int.TryParse(raw, out var intVal))
        return new IntegerLiteral(intVal);

    if (raw.StartsWith('"') && raw.EndsWith('"'))
        return new StringLiteral(raw[1..^1]);

    return new Identifier(raw);
}

The consumer:

var token = _parser.ParseToken(input);

string description = token.Match(
    withT1: i => $"Integer: {i.Value}",
    withT2: s => $"String: \"{s.Value}\"",
    withT3: id => $"Identifier: {id.Name}"
);

Switch: Side Effects

public void Switch(
    Action<T1> withT1,
    Action<T2> withT2)
{
    switch (_index)
    {
        case 0: withT1((T1)_value); break;
        case 1: withT2((T2)_value); break;
        default: throw new InvalidOperationException("Invalid index");
    }
}

Switch is the void-returning counterpart to Match. Use it when you need to perform side effects rather than compute a value:

OneOf<Success, Error> result = ProcessPayment(order);

result.Switch(
    withT1: success =>
    {
        _logger.LogInformation("Payment processed: {TransactionId}", success.TransactionId);
        _eventBus.Publish(new PaymentCompleted(success.TransactionId));
    },
    withT2: error =>
    {
        _logger.LogWarning("Payment failed: {Reason}", error.Reason);
        _metrics.IncrementCounter("payment.failures");
    }
);

Why separate Match and Switch? For the same reason that Option<T> separates them: intent clarity. Match is a pure transformation. Switch is effectful. When you see Match, you know a value is being produced. When you see Switch, you know side effects are being performed. Conflating the two (by using Match with a dummy return value) obscures the code's intent.

The Exhaustiveness Guarantee

The compiler enforces exhaustiveness through a mechanism so simple it is almost invisible: method signature arity.

OneOf<T1, T2>.Match requires exactly two functions. OneOf<T1, T2, T3>.Match requires exactly three. OneOf<T1, T2, T3, T4>.Match requires exactly four. There is no overload that takes fewer. There is no default parameter. There is no optional branch.

This means:

  1. You cannot forget a branch. If you provide one function instead of two, the code does not compile. The error message is clear: No overload for method 'Match' takes 1 arguments.

  2. Adding a variant breaks all callers. If you change a return type from OneOf<A, B> to OneOf<A, B, C>, every Match and Switch call on that type fails to compile. The compiler tells you exactly where to add the new handler.

  3. Removing a variant breaks all callers. If you change from OneOf<A, B, C> to OneOf<A, B>, every three-argument Match call fails. You are forced to review and remove the handler for the removed variant.

Compare this with a switch expression on a record hierarchy:

// This compiles even if you add a new PaymentMethod subtype
string description = payment switch
{
    CreditCard card => $"Card ending in {card.Number[^4..]}",
    BankTransfer bank => $"IBAN {bank.Iban}",
    _ => "Unknown payment method"  // silently catches everything else
};

The _ discard pattern is the escape hatch that defeats exhaustiveness. It looks safe, but it means that when you add Cryptocurrency next month, the compiler will not tell you. The switch will silently fall through to "Unknown payment method," and you will discover the bug in production.

Match has no escape hatch. Every variant gets a handler, or the code does not compile. This is the entire value proposition of the union pattern.


TryGet: Safe Type-Based Extraction

Sometimes you need to check for a specific type without writing a full Match. The TryGet<T> method provides a safe, pattern-matching-friendly way to do this:

public bool TryGet<T>(out T value) where T : notnull
{
    if (_value is T typed)
    {
        value = typed;
        return true;
    }

    value = default!;
    return false;
}

Usage:

OneOf<Success, ValidationError, NotFoundError> result = GetOrder(orderId);

if (result.TryGet<ValidationError>(out var validationError))
{
    foreach (var error in validationError.Errors)
    {
        _logger.LogWarning("Validation error: {Error}", error);
    }
}

TryGet<T> is useful in two scenarios.

Scenario 1: You only care about one variant. You want to extract a specific variant and ignore the rest. Match would force you to handle all branches, even if those branches are no-ops. TryGet lets you focus on the variant you care about.

// With Match: verbose no-ops for uninteresting branches
result.Switch(
    withT1: _ => { },                          // don't care about success
    withT2: err => HandleValidationError(err),  // only care about this one
    withT3: _ => { }                           // don't care about not-found
);

// With TryGet: focused on the variant that matters
if (result.TryGet<ValidationError>(out var err))
{
    HandleValidationError(err);
}

Scenario 2: Interop with existing code. You are integrating with code that expects a bool + out pattern -- the standard .NET "try" pattern used by Dictionary.TryGetValue, int.TryParse, and so on. TryGet<T> speaks that same protocol.

TryGet vs Is + As

TryGet<T> and the IsT1/AsT1 pattern serve similar purposes but differ in important ways:

// Is + As: positional, not type-based
if (result.IsT2)
{
    var error = result.AsT2;  // you must know that T2 is ValidationError
    HandleError(error);
}

// TryGet: type-based, self-documenting
if (result.TryGet<ValidationError>(out var error))
{
    HandleError(error);  // the type is explicit in the call
}

Is + As is positional: you must remember that T2 is ValidationError. If someone reorders the type parameters (changing OneOf<Success, ValidationError> to OneOf<ValidationError, Success>), the IsT2 check silently breaks.

TryGet<T> is type-based: you ask for ValidationError by name. If someone reorders the type parameters, the TryGet<ValidationError> call still works correctly because it matches on the runtime type, not the position.

That said, Match remains the recommended primary API because it enforces exhaustiveness. Use TryGet for the focused, single-variant scenarios described above.


Equality and Hashing

OneOf<T1, T2> implements IEquatable<OneOf<T1, T2>> for value-based equality. Two union instances are equal if they hold the same variant index and the same value:

public bool Equals(OneOf<T1, T2>? other)
{
    if (other is null) return false;
    return _index == other._index && _value.Equals(other._value);
}

public override bool Equals(object? obj)
{
    return obj is OneOf<T1, T2> other && Equals(other);
}

This means:

OneOf<string, int> a = "hello";
OneOf<string, int> b = "hello";
OneOf<string, int> c = 42;
OneOf<string, int> d = "world";

a.Equals(b);  // true  -- same index (0), same value ("hello")
a.Equals(c);  // false -- different index (0 vs 1)
a.Equals(d);  // false -- same index (0), different value ("hello" vs "world")

Value equality is essential for using unions as dictionary keys, in HashSet<T>, in LINQ Distinct() calls, and in test assertions:

// Dictionary key
var cache = new Dictionary<OneOf<string, int>, CachedResult>();
cache[OneOf<string, int>.From("key")] = result;

// HashSet
var seen = new HashSet<OneOf<string, int>>();
seen.Add("hello");  // implicit conversion
seen.Add("hello");  // not added, already present
seen.Count;         // 1

// Test assertion
var expected = OneOf<string, int>.From("hello");
var actual = SomeMethod();
Assert.Equal(expected, actual);  // value equality, not reference equality

GetHashCode: Conditional Compilation

The GetHashCode implementation varies based on the target framework, using conditional compilation to provide the best available implementation:

public override int GetHashCode()
{
#if NETSTANDARD2_0
    unchecked
    {
        return (_index * 397) ^ _value.GetHashCode();
    }
#else
    return HashCode.Combine(_index, _value);
#endif
}

On .NET 5+: HashCode.Combine is the standard library's hash combiner. It uses a well-distributed hash function (xxHash32-derived) and handles null values gracefully. This is the idiomatic approach for modern .NET.

On netstandard2.0: HashCode does not exist. The fallback uses the classic multiply-and-XOR pattern with the prime number 397 (a convention popularized by ReSharper's auto-generated GetHashCode implementations). The unchecked block prevents overflow exceptions on 32-bit integer arithmetic. This is not as well-distributed as HashCode.Combine, but it is correct and sufficient for the use cases where unions appear as hash keys.

Why does the Union package target netstandard2.0 at all? Because it has no external dependencies. It does not reference FrenchExDev.Net.Result or any other package. It is a standalone, zero-dependency library that works on .NET Framework 4.6.1+, .NET Core 2.0+, .NET 5+, and .NET 10. The conditional compilation is the cost of that broad compatibility.

Equality Semantics: What Counts as "Equal"

A subtlety worth noting: equality depends on the Equals implementation of the contained value, not on reference identity.

var record1 = new UserDto("Alice", "alice@example.com");
var record2 = new UserDto("Alice", "alice@example.com");

// Records have value equality
OneOf<UserDto, Error> a = record1;
OneOf<UserDto, Error> b = record2;
a.Equals(b);  // true -- record value equality

// Classes have reference equality by default
var obj1 = new LegacyUser { Name = "Alice" };
var obj2 = new LegacyUser { Name = "Alice" };

OneOf<LegacyUser, Error> c = obj1;
OneOf<LegacyUser, Error> d = obj2;
c.Equals(d);  // false -- different object references (unless LegacyUser overrides Equals)

This is the expected behavior: the union defers to the contained type's equality semantics. If you want value equality in your unions, use records or types that override Equals and GetHashCode. If the contained types have reference equality, the union has reference equality for that variant. The union does not impose a different equality contract on the types it wraps.


When to Use Which: Union vs Option vs Result

FrenchExDev provides three types that superficially look similar: Option<T>, Result<T>, and OneOf<T1, ..., TN>. All three represent "a value that could be one of several things." The difference is in what those "things" are and what guarantees each type provides.

Diagram
The three-way decision — Option, Result, OneOf — mapped to the question the caller is actually asking: is the value missing, did it fail, or is it one shape out of several?

The Comparison Table

Dimension Option<T> Result<T> OneOf<T1, ..., TN>
Purpose Presence or absence Success or failure One of N distinct types
Variants 2 (Some, None) 2 (Success, Failure) 2, 3, or 4
Error information None (absence is unexplained) Yes (Failure carries error details) Each variant carries its own type
Exhaustive matching Yes (Match requires onSome + onNone) Yes (Match requires onSuccess + onFailure) Yes (Match requires one handler per variant)
Composable pipeline Yes (Map, Bind, Filter, Zip) Yes (Map, Bind, Ensure) No (Match is the primary consumer)
LINQ support Yes (from/select) No No
Typical use case Lookups, optional config, nullable interop Operations that can fail with a reason Closed sets of domain alternatives
Package FrenchExDev.Net.Options FrenchExDev.Net.Result FrenchExDev.Net.Union

When to Use Option

Use Option<T> when a value might not exist, and the reason for absence is not important.

// Good: the caller does not need to know WHY the user was not found
Option<User> FindUserById(int id);

// Good: the config key might not be set, and that is fine
Option<string> GetConfigValue(string key);

// Bad: if the caller needs to know why the value is missing, use Result<T>
// Option<User> AuthenticateUser(string token);  // Why did auth fail?

When to Use Result

Use Result<T> when an operation can succeed or fail, and the failure carries meaningful information.

// Good: the caller needs to know why creation failed
Result<Order> CreateOrder(CreateOrderCommand command);

// Good: validation produces specific error messages
Result<ValidatedInput> Validate(RawInput input);

// Bad: if there are more than two outcomes, use OneOf
// Result<T> does not model "success, client error, or server error" cleanly

When to Use OneOf

Use OneOf<T1, ..., TN> when a value is one of N distinct types, and none of those types represents "failure" in the Result sense.

// Good: three distinct payment methods, none is an error
OneOf<CreditCard, BankTransfer, Cryptocurrency> PaymentMethod;

// Good: three distinct API responses, each with different structure
OneOf<SuccessResponse, NotFoundResponse, ForbiddenResponse> ApiResponse;

// Good: parser output that can be different token types
OneOf<IntegerLiteral, StringLiteral, Identifier> Token;

The Gray Area

Some scenarios genuinely fall between the types. Consider a method that returns either a user or one of three specific error types:

// Option 1: OneOf with error variants
OneOf<User, NotFound, Forbidden, ValidationErrors> FindAndAuthorizeUser(int id, ClaimsPrincipal principal);

// Option 2: Result<T> with a typed error union
Result<User> FindAndAuthorizeUser(int id, ClaimsPrincipal principal);
// where Failure carries a OneOf<NotFound, Forbidden, ValidationErrors>

Both are valid. Option 1 is more explicit: the four variants are all first-class. Option 2 is more conventional: the caller uses Result<T>'s pipeline operators (Map, Bind) for the success path and only unpacks the error union when needed. Choose based on whether the caller primarily works with the success path (use Result) or needs to distinguish between all variants equally (use OneOf).


OneOf<T1, T2, T3>

The three-variant union follows the same pattern as the two-variant union, with one additional type parameter, factory method, property, and Match/Switch branch:

public sealed class OneOf<T1, T2, T3> : IEquatable<OneOf<T1, T2, T3>>
    where T1 : notnull where T2 : notnull where T3 : notnull

Creation:

OneOf<string, int, bool> fromString = "hello";     // implicit, index 0
OneOf<string, int, bool> fromInt = 42;              // implicit, index 1
OneOf<string, int, bool> fromBool = true;           // implicit, index 2

// Explicit factory
var explicit = OneOf<string, int, bool>.From(3.14);  // compile error: no From(double)

Matching:

OneOf<string, int, bool> value = GetConfigValue(key);

string display = value.Match(
    withT1: s => $"String: {s}",
    withT2: i => $"Integer: {i}",
    withT3: b => $"Boolean: {b}"
);

Properties:

value.IsT1  // true if string
value.IsT2  // true if int
value.IsT3  // true if bool

value.AsT1  // throws if not string
value.AsT2  // throws if not int
value.AsT3  // throws if not bool

Real Use Case: Configuration Values

Configuration systems often store values that can be strings, numbers, or booleans:

public sealed class TypedConfigEntry
{
    public string Key { get; }
    public OneOf<string, int, bool> Value { get; }

    public TypedConfigEntry(string key, OneOf<string, int, bool> value)
    {
        Key = key;
        Value = value;
    }

    public string ToDisplayString() => Value.Match(
        withT1: s => $"{Key} = \"{s}\"",
        withT2: i => $"{Key} = {i}",
        withT3: b => $"{Key} = {(b ? "true" : "false")}"
    );

    public string ToJsonValue() => Value.Match(
        withT1: s => $"\"{s}\"",
        withT2: i => i.ToString(),
        withT3: b => b ? "true" : "false"
    );
}

Usage:

var entries = new List<TypedConfigEntry>
{
    new("app.name", "My Application"),
    new("app.port", 8080),
    new("app.debug", true)
};

foreach (var entry in entries)
{
    Console.WriteLine(entry.ToDisplayString());
}
// app.name = "My Application"
// app.port = 8080
// app.debug = true

OneOf<T1, T2, T3, T4>

The four-variant union is the largest arity provided:

public sealed class OneOf<T1, T2, T3, T4> : IEquatable<OneOf<T1, T2, T3, T4>>
    where T1 : notnull where T2 : notnull where T3 : notnull where T4 : notnull

Four variants means four From methods, four IsT properties, four AsT accessors, and four branches in Match and Switch.

public record Success(OrderDto Order);
public record NotFound(string Resource, string Id);
public record Forbidden(string Reason);
public record ValidationError(string[] Errors);

public OneOf<Success, NotFound, Forbidden, ValidationError> ProcessOrder(
    string orderId,
    ClaimsPrincipal user)
{
    var errors = Validate(orderId);
    if (errors.Length > 0)
        return new ValidationError(errors);

    if (!_authService.CanAccessOrder(user, orderId))
        return new Forbidden("User does not have access to this order");

    var order = _repository.FindById(orderId);
    if (order is null)
        return new NotFound("Order", orderId);

    return new Success(_mapper.Map(order));
}

The caller:

var result = _service.ProcessOrder(orderId, User);

IActionResult response = result.Match(
    withT1: success => Ok(success.Order),
    withT2: notFound => NotFound($"{notFound.Resource} {notFound.Id}"),
    withT3: forbidden => StatusCode(403, forbidden.Reason),
    withT4: validation => BadRequest(new { Errors = validation.Errors })
);

When to Use Each Arity

Arity Use When Example
OneOf<T1, T2> Binary choice between two types OneOf<Success, Error>, OneOf<Left, Right>
OneOf<T1, T2, T3> Three distinct outcomes OneOf<Found, NotFound, Error>, OneOf<Card, Bank, Crypto>
OneOf<T1, T2, T3, T4> Four distinct outcomes OneOf<Success, NotFound, Forbidden, ValidationError>
More than 4 Rethink your design Decompose into smaller unions or use a record hierarchy

The guideline is simple: if you need five or more variants, the union is not the right tool. Five lambdas in a Match call is already hard to read. Six is worse. Seven is a code smell. At that point, consider:

  • Can some variants be merged? (e.g., "NotFound" and "Gone" are both "resource unavailable")
  • Can the union be split into two steps? (e.g., first determine the category, then determine the specific case within that category)
  • Should this be a record hierarchy with the Visitor pattern instead?

Real-World Example: Payment Processing

One of the most natural applications of discriminated unions is modeling closed sets of domain alternatives. Payment methods are a perfect example: a business accepts credit cards, bank transfers, and cryptocurrency. There will never be a fourth option unless the business explicitly adds one. The set is closed.

The Domain Types

public sealed record CreditCard(
    string CardNumber,
    string CardholderName,
    string ExpiryDate,
    string Cvv)
{
    public string MaskedNumber => $"****-****-****-{CardNumber[^4..]}";
}

public sealed record BankTransfer(
    string Iban,
    string Bic,
    string AccountHolderName,
    string BankName)
{
    public string MaskedIban => $"{Iban[..4]}****{Iban[^4..]}";
}

public sealed record Cryptocurrency(
    string WalletAddress,
    string Network,
    string CurrencyCode)
{
    public string ShortAddress => $"{WalletAddress[..6]}...{WalletAddress[^4..]}";
}

The Union Type

// A payment method is exactly one of these three types
public static class PaymentMethod
{
    // Type alias for clarity
    public static OneOf<CreditCard, BankTransfer, Cryptocurrency> FromCard(CreditCard card)
        => card;

    public static OneOf<CreditCard, BankTransfer, Cryptocurrency> FromBank(BankTransfer bank)
        => bank;

    public static OneOf<CreditCard, BankTransfer, Cryptocurrency> FromCrypto(Cryptocurrency crypto)
        => crypto;
}

The Payment Handler

public sealed class PaymentHandler
{
    private readonly ICreditCardGateway _cardGateway;
    private readonly IBankTransferGateway _bankGateway;
    private readonly ICryptoGateway _cryptoGateway;
    private readonly ILogger<PaymentHandler> _logger;

    public PaymentHandler(
        ICreditCardGateway cardGateway,
        IBankTransferGateway bankGateway,
        ICryptoGateway cryptoGateway,
        ILogger<PaymentHandler> logger)
    {
        _cardGateway = cardGateway;
        _bankGateway = bankGateway;
        _cryptoGateway = cryptoGateway;
        _logger = logger;
    }

    public async Task<Result<PaymentReceipt>> ProcessAsync(
        OneOf<CreditCard, BankTransfer, Cryptocurrency> method,
        decimal amount,
        string currency)
    {
        _logger.LogInformation(
            "Processing {Amount} {Currency} payment via {Method}",
            amount,
            currency,
            method.Match(
                withT1: _ => "credit card",
                withT2: _ => "bank transfer",
                withT3: _ => "cryptocurrency"));

        return await method.Match(
            withT1: card => _cardGateway.ChargeAsync(card, amount, currency),
            withT2: bank => _bankGateway.TransferAsync(bank, amount, currency),
            withT3: crypto => _cryptoGateway.SendAsync(crypto, amount, currency));
    }

    public string GetPaymentSummary(
        OneOf<CreditCard, BankTransfer, Cryptocurrency> method)
    {
        return method.Match(
            withT1: card => $"Credit card ending in {card.MaskedNumber}",
            withT2: bank => $"Bank transfer to {bank.MaskedIban} ({bank.BankName})",
            withT3: crypto => $"{crypto.CurrencyCode} wallet {crypto.ShortAddress} ({crypto.Network})"
        );
    }
}

End-to-End Usage

// Creating payment methods from user input
OneOf<CreditCard, BankTransfer, Cryptocurrency> method = paymentType switch
{
    "card" => new CreditCard(cardNumber, holderName, expiry, cvv),
    "bank" => new BankTransfer(iban, bic, holderName, bankName),
    "crypto" => new Cryptocurrency(walletAddress, network, currencyCode),
    _ => throw new ArgumentException($"Unknown payment type: {paymentType}")
};

// Processing
var receipt = await _paymentHandler.ProcessAsync(method, 99.99m, "EUR");

receipt.Match(
    onSuccess: r => Console.WriteLine($"Payment complete. Receipt: {r.Id}"),
    onFailure: f => Console.WriteLine($"Payment failed: {f.Message}")
);

// Displaying summary
Console.WriteLine(_paymentHandler.GetPaymentSummary(method));
// "Credit card ending in ****-****-****-4242"

This example demonstrates the key strengths of the union pattern in domain modeling:

  1. Closed set. You cannot accidentally add a new payment method without updating every Match call in the codebase.
  2. Type safety. Each variant carries its own strongly-typed data. There is no object PaymentData field that you cast at runtime.
  3. Exhaustive handling. The ProcessAsync method handles all three payment types, and the compiler guarantees this.
  4. Composability with Result. The return type Result<PaymentReceipt> composes the union's exhaustive dispatch with the Result pattern's success/failure semantics.

Real-World Example: API Response

Web APIs must return different HTTP status codes with different response bodies depending on the outcome. A common pattern is to use IActionResult as the return type, which is maximally flexible but provides no compile-time safety -- the caller has no idea what status codes or response shapes to expect.

Unions solve this by making the response types explicit:

The Response Types

public sealed record SuccessResponse<T>(T Data, string? Message = null);
public sealed record NotFoundResponse(string Resource, string Id);
public sealed record ForbiddenResponse(string Reason);
public sealed record ValidationErrorResponse(IDictionary<string, string[]> Errors);

The Service Layer

public interface IOrderService
{
    OneOf<SuccessResponse<OrderDto>, NotFoundResponse, ForbiddenResponse, ValidationErrorResponse>
        GetOrder(string orderId, ClaimsPrincipal user);
}

public sealed class OrderService : IOrderService
{
    private readonly IOrderRepository _repository;
    private readonly IAuthorizationService _auth;
    private readonly IMapper<Order, OrderDto> _mapper;

    public OrderService(
        IOrderRepository repository,
        IAuthorizationService auth,
        IMapper<Order, OrderDto> mapper)
    {
        _repository = repository;
        _auth = auth;
        _mapper = mapper;
    }

    public OneOf<SuccessResponse<OrderDto>, NotFoundResponse, ForbiddenResponse, ValidationErrorResponse>
        GetOrder(string orderId, ClaimsPrincipal user)
    {
        // Validation
        if (string.IsNullOrWhiteSpace(orderId))
        {
            return new ValidationErrorResponse(
                new Dictionary<string, string[]>
                {
                    ["orderId"] = new[] { "Order ID is required" }
                });
        }

        // Authorization
        if (!_auth.CanViewOrder(user, orderId))
        {
            return new ForbiddenResponse(
                $"User {user.Identity?.Name} is not authorized to view order {orderId}");
        }

        // Retrieval
        var order = _repository.FindById(orderId);
        if (order is null)
        {
            return new NotFoundResponse("Order", orderId);
        }

        return new SuccessResponse<OrderDto>(_mapper.Map(order));
    }
}

The Controller

[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;

    public OrdersController(IOrderService orderService)
    {
        _orderService = orderService;
    }

    [HttpGet("{orderId}")]
    [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status403Forbidden)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public IActionResult GetOrder(string orderId)
    {
        var result = _orderService.GetOrder(orderId, User);

        return result.Match(
            withT1: success => Ok(success.Data),
            withT2: notFound => NotFound(new { notFound.Resource, notFound.Id }),
            withT3: forbidden => StatusCode(403, new { forbidden.Reason }),
            withT4: validation => BadRequest(new { validation.Errors })
        );
    }
}

Every response path is explicit. The service declares exactly which outcomes are possible. The controller handles every outcome with a type-safe match. If the service adds a fifth outcome (say, ServiceUnavailableResponse), the union type changes to OneOf<..., ServiceUnavailableResponse> -- which exceeds the four-variant limit, signaling that the design should be reconsidered, perhaps by splitting the service into smaller, more focused operations.


Testing Unions

Testing union types requires verifying both the variant selection and the contained value. The approach depends on whether you want to assert the exact variant, test a specific branch of a Match, or verify equality.

Testing Variant Selection

The simplest test pattern uses IsT1/IsT2 to verify which variant was selected:

[Fact]
public void FindUser_ExistingId_ReturnsUser()
{
    // Arrange
    var service = CreateService(existingUsers: new[] { new User(1, "Alice") });

    // Act
    OneOf<User, NotFoundError> result = service.FindUser(1);

    // Assert
    Assert.True(result.IsT1, "Expected a User (T1), but got NotFoundError (T2)");
    Assert.Equal("Alice", result.AsT1.Name);
}

[Fact]
public void FindUser_MissingId_ReturnsNotFound()
{
    // Arrange
    var service = CreateService(existingUsers: Array.Empty<User>());

    // Act
    OneOf<User, NotFoundError> result = service.FindUser(999);

    // Assert
    Assert.True(result.IsT2, "Expected NotFoundError (T2), but got User (T1)");
    Assert.Equal("999", result.AsT2.Id);
}

Testing with Match

For more expressive tests, use Match to extract and assert in one step:

[Fact]
public void ProcessPayment_ValidCard_ReturnsSuccess()
{
    // Arrange
    var handler = CreatePaymentHandler();
    OneOf<CreditCard, BankTransfer, Cryptocurrency> method =
        new CreditCard("4111111111111111", "Alice", "12/28", "123");

    // Act
    var result = handler.ProcessSync(method, 99.99m, "EUR");

    // Assert
    result.Match(
        withT1: success =>
        {
            Assert.NotNull(success.TransactionId);
            Assert.Equal(99.99m, success.Amount);
        },
        withT2: _ => Assert.Fail("Expected success, got validation error"),
        withT3: _ => Assert.Fail("Expected success, got processing error")
    );
}

Testing with TryGet

TryGet provides a clean way to assert a specific variant without handling the others:

[Fact]
public void ParseToken_IntegerInput_ReturnsIntegerLiteral()
{
    // Arrange & Act
    var result = _parser.ParseToken("42");

    // Assert
    Assert.True(result.TryGet<IntegerLiteral>(out var literal));
    Assert.Equal(42, literal.Value);
}

[Fact]
public void ParseToken_QuotedInput_ReturnsStringLiteral()
{
    // Arrange & Act
    var result = _parser.ParseToken("\"hello\"");

    // Assert
    Assert.True(result.TryGet<StringLiteral>(out var literal));
    Assert.Equal("hello", literal.Value);
}

Testing Equality

[Fact]
public void OneOf_SameVariantSameValue_AreEqual()
{
    OneOf<string, int> a = "hello";
    OneOf<string, int> b = "hello";

    Assert.Equal(a, b);
    Assert.True(a.Equals(b));
    Assert.Equal(a.GetHashCode(), b.GetHashCode());
}

[Fact]
public void OneOf_DifferentVariant_AreNotEqual()
{
    OneOf<string, int> a = "42";
    OneOf<string, int> b = 42;

    Assert.NotEqual(a, b);
    Assert.False(a.Equals(b));
}

[Fact]
public void OneOf_SameVariantDifferentValue_AreNotEqual()
{
    OneOf<string, int> a = "hello";
    OneOf<string, int> b = "world";

    Assert.NotEqual(a, b);
}

Testing Match Exhaustiveness at Refactor Time

One of the most valuable properties of unions in a test suite is that tests break when you add a variant. Suppose you have:

public OneOf<Created, Conflict> CreateUser(CreateUserCommand cmd);

And you decide to add a validation error variant:

public OneOf<Created, Conflict, ValidationError> CreateUser(CreateUserCommand cmd);

Every test that calls Match on the old OneOf<Created, Conflict> now fails to compile:

// Before: compiles
result.Match(
    withT1: created => /* ... */,
    withT2: conflict => /* ... */
);

// After: does not compile -- missing withT3
// error CS1501: No overload for method 'Match' takes 2 arguments

The compiler tells you exactly which tests need updating. You add the withT3 handler to each test, and the test suite is back in a consistent state. No silent failures. No forgotten cases. No bugs in production because a test did not cover the new variant.

A Custom Assertion Helper

If your test suite uses unions extensively, a small helper method can reduce boilerplate:

public static class UnionAssert
{
    public static T1 AssertIsT1<T1, T2>(OneOf<T1, T2> union)
        where T1 : notnull where T2 : notnull
    {
        Assert.True(union.IsT1,
            $"Expected T1 ({typeof(T1).Name}), but got T2 ({typeof(T2).Name}): {union}");
        return union.AsT1;
    }

    public static T2 AssertIsT2<T1, T2>(OneOf<T1, T2> union)
        where T1 : notnull where T2 : notnull
    {
        Assert.True(union.IsT2,
            $"Expected T2 ({typeof(T2).Name}), but got T1 ({typeof(T1).Name}): {union}");
        return union.AsT2;
    }

    public static T1 AssertIsT1<T1, T2, T3>(OneOf<T1, T2, T3> union)
        where T1 : notnull where T2 : notnull where T3 : notnull
    {
        Assert.True(union.IsT1,
            $"Expected T1 ({typeof(T1).Name}), but got: {union}");
        return union.AsT1;
    }

    // ... additional overloads for other variants and arities
}

Usage:

[Fact]
public void FindUser_ExistingId_ReturnsUser()
{
    var result = _service.FindUser(1);

    var user = UnionAssert.AssertIsT1(result);
    Assert.Equal("Alice", user.Name);
}

The helper method both asserts the variant and returns the typed value, eliminating the separate Assert.True + AsT1 two-step.


DI and Composition

Unions integrate naturally with dependency injection. Because OneOf is a plain generic type with no special DI requirements, it can appear as the return type of any service interface. When combined with FrenchExDev's [Injectable] source generator, union-returning services are registered automatically.

Service Interface with Union Returns

public interface IPaymentProcessor
{
    Task<OneOf<PaymentSuccess, PaymentDeclined, PaymentError>> ProcessAsync(
        OneOf<CreditCard, BankTransfer, Cryptocurrency> method,
        Money amount);
}

The interface declares both what it accepts (a union of payment methods) and what it returns (a union of outcomes). The caller knows at compile time that exactly three outcomes are possible and must handle all of them.

Implementation with [Injectable]

[Injectable(typeof(IPaymentProcessor), Lifetime.Scoped)]
public sealed class PaymentProcessor : IPaymentProcessor
{
    private readonly ICreditCardGateway _cardGateway;
    private readonly IBankTransferGateway _bankGateway;
    private readonly ICryptoGateway _cryptoGateway;

    public PaymentProcessor(
        ICreditCardGateway cardGateway,
        IBankTransferGateway bankGateway,
        ICryptoGateway cryptoGateway)
    {
        _cardGateway = cardGateway;
        _bankGateway = bankGateway;
        _cryptoGateway = cryptoGateway;
    }

    public async Task<OneOf<PaymentSuccess, PaymentDeclined, PaymentError>> ProcessAsync(
        OneOf<CreditCard, BankTransfer, Cryptocurrency> method,
        Money amount)
    {
        try
        {
            return await method.Match(
                withT1: card => ProcessCardAsync(card, amount),
                withT2: bank => ProcessBankAsync(bank, amount),
                withT3: crypto => ProcessCryptoAsync(crypto, amount));
        }
        catch (PaymentGatewayException ex)
        {
            return new PaymentError(ex.Message, ex.ErrorCode);
        }
    }

    private async Task<OneOf<PaymentSuccess, PaymentDeclined, PaymentError>> ProcessCardAsync(
        CreditCard card, Money amount)
    {
        var gatewayResult = await _cardGateway.ChargeAsync(card, amount);
        if (gatewayResult.IsApproved)
            return new PaymentSuccess(gatewayResult.TransactionId, amount);

        return new PaymentDeclined(gatewayResult.DeclineReason);
    }

    // ... ProcessBankAsync, ProcessCryptoAsync follow the same pattern
}

The [Injectable] attribute causes the source generator to emit a registration line in the assembly's AddInjectables method:

// Source-generated
services.AddScoped<IPaymentProcessor, PaymentProcessor>();

No manual registration. No Startup.cs that grows to 200 lines. The service is registered because it is annotated, and the union types in its signatures are just generic type parameters -- no special DI handling required.

Consuming Union-Returning Services

[Injectable(typeof(ICheckoutOrchestrator), Lifetime.Scoped)]
public sealed class CheckoutOrchestrator : ICheckoutOrchestrator
{
    private readonly IPaymentProcessor _paymentProcessor;
    private readonly IOrderRepository _orderRepository;
    private readonly ILogger<CheckoutOrchestrator> _logger;

    public CheckoutOrchestrator(
        IPaymentProcessor paymentProcessor,
        IOrderRepository orderRepository,
        ILogger<CheckoutOrchestrator> logger)
    {
        _paymentProcessor = paymentProcessor;
        _orderRepository = orderRepository;
        _logger = logger;
    }

    public async Task<OneOf<OrderConfirmation, CheckoutFailure>> CheckoutAsync(
        Order order,
        OneOf<CreditCard, BankTransfer, Cryptocurrency> paymentMethod)
    {
        var paymentResult = await _paymentProcessor.ProcessAsync(paymentMethod, order.Total);

        return paymentResult.Match(
            withT1: success =>
            {
                order.MarkAsPaid(success.TransactionId);
                _orderRepository.Save(order);
                return (OneOf<OrderConfirmation, CheckoutFailure>)
                    new OrderConfirmation(order.Id, success.TransactionId);
            },
            withT2: declined =>
            {
                _logger.LogWarning("Payment declined: {Reason}", declined.Reason);
                return (OneOf<OrderConfirmation, CheckoutFailure>)
                    new CheckoutFailure($"Payment declined: {declined.Reason}");
            },
            withT3: error =>
            {
                _logger.LogError("Payment error: {Message} ({Code})", error.Message, error.Code);
                return (OneOf<OrderConfirmation, CheckoutFailure>)
                    new CheckoutFailure($"Payment processing error: {error.Message}");
            }
        );
    }
}

Notice how the three-variant payment result is collapsed into a two-variant checkout result. The PaymentDeclined and PaymentError variants are both mapped to CheckoutFailure because at the checkout level, the distinction between "declined" and "error" does not matter to the caller -- both mean the checkout failed. This is a natural and common pattern when composing unions across service boundaries: the inner union has more variants than the outer union needs.


The Handle-or-Propagate Pattern

A common pattern when composing services is to handle one variant and propagate the rest. For example, a middleware that handles validation errors but forwards successes and not-found results:

public OneOf<OrderDto, NotFoundError> GetOrderWithValidation(string orderId)
{
    var result = _service.GetOrder(orderId);

    return result.Match(
        withT1: success => (OneOf<OrderDto, NotFoundError>)success.Order,
        withT2: notFound => notFound,
        withT3: validation =>
        {
            // Handle validation errors locally
            _logger.LogWarning("Validation failed: {Errors}",
                string.Join(", ", validation.Errors));
            throw new ValidationException(validation.Errors);
        }
    );
}

The three-variant union from the service is narrowed to a two-variant union by handling the third variant locally. The remaining two variants pass through unchanged.

Mapping Between Union Types

Sometimes you need to transform the types inside a union without changing the union structure:

// Service returns domain types
OneOf<Order, NotFoundError, ForbiddenError> domainResult = _service.GetOrder(id);

// Controller needs DTO types
OneOf<OrderDto, NotFoundResponse, ForbiddenResponse> apiResult = domainResult.Match(
    withT1: order => (OneOf<OrderDto, NotFoundResponse, ForbiddenResponse>)
        _mapper.Map(order),
    withT2: notFound => new NotFoundResponse(notFound.Resource, notFound.Id),
    withT3: forbidden => new ForbiddenResponse(forbidden.Reason)
);

Each variant is independently mapped to its API counterpart. The union structure is preserved -- three variants in, three variants out -- but the types change.

Combining Unions with Result

Unions and Results compose cleanly. The most common composition is a method that returns Result<OneOf<...>> or OneOf<..., Result<T>>:

// Pattern 1: Result wrapping a union
// "The operation might fail; if it succeeds, the outcome is one of N types"
public async Task<Result<OneOf<CacheHit, CacheMiss>>> TryGetFromCacheAsync(string key)
{
    try
    {
        var cached = await _cache.GetAsync(key);
        if (cached is not null)
            return Result<OneOf<CacheHit, CacheMiss>>.Success(new CacheHit(cached));

        return Result<OneOf<CacheHit, CacheMiss>>.Success(new CacheMiss());
    }
    catch (CacheException ex)
    {
        return Result<OneOf<CacheHit, CacheMiss>>.Failure(ex.Message);
    }
}
// Pattern 2: Union containing a Result
// "The outcome is one of N types, and one of those types is a failure"
public OneOf<OrderDto, Result<OrderDto>> GetOrderWithFallback(string id)
{
    var cached = _cache.Get<OrderDto>(id);
    if (cached is not null)
        return cached;  // T1: cache hit, no error possible

    return _repository.FindById(id);  // T2: might fail with Result.Failure
}

Pattern 1 is more common and more natural. Pattern 2 is unusual and usually a sign that the API should be redesigned -- if one of the variants is a Result, you probably want the outer type to be Result as well.

The Narrowing Pattern

When a union has variants that become irrelevant at a certain point in the pipeline, you can narrow the union:

// Full union from the service
OneOf<User, NotFound, Forbidden, ValidationError> fullResult = _service.GetUser(id, principal);

// After authentication middleware has handled Forbidden:
OneOf<User, NotFound, ValidationError> authenticated = fullResult.Match(
    withT1: user => (OneOf<User, NotFound, ValidationError>)user,
    withT2: notFound => notFound,
    withT3: _ => throw new ForbiddenException(),  // handled, will not reach here
    withT4: validation => validation
);

Each layer of the pipeline handles the variants it is responsible for and narrows the union for the next layer. By the time the union reaches the final consumer, it may have been narrowed from four variants down to two or even one.

The Widening Pattern

The opposite is also useful: widening a union by adding a new variant at a boundary:

// Inner service returns two variants
OneOf<Order, NotFoundError> innerResult = _repository.GetOrder(id);

// Outer service adds an authorization check, widening to three variants
OneOf<Order, NotFoundError, ForbiddenError> outerResult = innerResult.Match(
    withT1: order =>
    {
        if (!CanAccess(order))
            return (OneOf<Order, NotFoundError, ForbiddenError>)
                new ForbiddenError("Access denied");
        return (OneOf<Order, NotFoundError, ForbiddenError>)order;
    },
    withT2: notFound => (OneOf<Order, NotFoundError, ForbiddenError>)notFound
);

The inner union's two variants are preserved, and a third variant is added by the outer layer. This is a natural way to compose authorization, validation, and other cross-cutting concerns without modifying the inner service's contract.


Performance Considerations

The Union package makes deliberate performance tradeoffs in favor of correctness and simplicity. Understanding these tradeoffs helps you decide when OneOf is appropriate and when a different approach might be better.

Boxing

When T1 or T2 is a value type (int, decimal, DateTime, a custom struct), the value is boxed when stored in the object _value field. This allocates a small object on the heap.

OneOf<string, int> x = 42;
// The int 42 is boxed into an object on the heap
// This costs one allocation (~24 bytes on x64)

For domain-level code -- service returns, API responses, command results -- this cost is negligible. The boxing allocation is dwarfed by the cost of the database query, HTTP call, or business logic that produced the value.

For hot-loop code -- tight inner loops processing millions of items per second -- boxing matters. In these scenarios, do not use OneOf. Use a custom struct with explicit fields, or use C# pattern matching on a hand-written tagged union.

Allocation

OneOf<T1, T2> is a class, so creating one allocates on the heap. Combined with potential boxing of the contained value, a single OneOf<string, int> x = 42; involves two allocations: one for the OneOf instance and one for boxing the int.

For reference types, there is only one allocation (the OneOf instance itself), because the _value field stores a reference to the existing object without copying it.

Method Call Overhead

Match invokes one delegate (lambda). Switch invokes one delegate. These are not virtual calls -- they are direct delegate invocations. The overhead is comparable to calling any other Func<T, TResult> or Action<T>.

The Right Mental Model

Think of OneOf like Task<T>: it is a wrapper type that you use at API boundaries and in service layers, not in inner loops. Task<T> allocates on the heap. Task<T> involves state machines. Nobody avoids Task<T> in service code because of allocation cost. The same reasoning applies to OneOf<T1, T2>: the safety it provides at domain boundaries is worth far more than the cost of a single heap allocation.


OneOf vs. the OneOf NuGet Package

The popular OneOf NuGet package by Harry McIntyre (nuget.org/packages/OneOf) provides a similar concept with arities up to OneOf<T0, ..., T8>. FrenchExDev's OneOf differs in several ways:

  1. Arity limit. FrenchExDev stops at four variants. This is intentional, not a limitation. The reasoning is explained above.
  2. Zero-based naming. The McIntyre package uses T0, T1, T2, .... FrenchExDev uses T1, T2, T3, T4 (one-based), matching C# generic conventions like Func<T1, T2, TResult> and Tuple<T1, T2>.
  3. Named lambda parameters. FrenchExDev's Match uses withT1, withT2, ... as parameter names, encouraging named arguments that self-document the handler's purpose.
  4. No base class. FrenchExDev's three arity classes are independent sealed classes. They do not share a common IOneOf interface or base class, keeping the type surface minimal.
  5. netstandard2.0 compatibility. FrenchExDev's Union works on .NET Framework 4.6.1+ without polyfills, with conditional compilation for HashCode.

OneOf vs. C# Switch on Records

// Record hierarchy approach
public abstract record PaymentResult;
public sealed record PaymentSuccess(string TransactionId) : PaymentResult;
public sealed record PaymentDeclined(string Reason) : PaymentResult;
public sealed record PaymentError(string Message, string Code) : PaymentResult;

string message = result switch
{
    PaymentSuccess s => $"Success: {s.TransactionId}",
    PaymentDeclined d => $"Declined: {d.Reason}",
    PaymentError e => $"Error: {e.Message}",
    _ => throw new InvalidOperationException("Unknown result type")
};

The _ default branch is the problem. It compiles even if you add a new subclass. It is the escape hatch that the compiler cannot close.

// OneOf approach
OneOf<PaymentSuccess, PaymentDeclined, PaymentError> result = Process();

string message = result.Match(
    withT1: s => $"Success: {s.TransactionId}",
    withT2: d => $"Declined: {d.Reason}",
    withT3: e => $"Error: {e.Message}"
);

No escape hatch. No default branch. If someone adds a fourth variant, the code does not compile until every Match is updated.

OneOf vs. Exceptions

Some developers model error cases using exceptions instead of union return types:

// Exception approach
public OrderDto GetOrder(string id)
{
    var order = _repository.FindById(id)
        ?? throw new NotFoundException("Order", id);

    if (!CanAccess(order))
        throw new ForbiddenException("Access denied");

    return _mapper.Map(order);
}

This works, but it has problems. Exceptions are invisible in the method signature -- the caller has no way to know which exceptions might be thrown without reading the implementation. Exceptions unwind the call stack, which makes them expensive relative to returning a value. And exceptions bypass the normal control flow, making the code harder to reason about in complex pipelines.

The union approach makes every possible outcome visible in the signature:

public OneOf<OrderDto, NotFoundError, ForbiddenError> GetOrder(string id, ClaimsPrincipal user)

The caller can see all three outcomes at a glance. The IDE shows them in IntelliSense. The compiler enforces that all three are handled. No hidden control flow, no performance cliff, no surprises.


Limitations and When Not to Use OneOf

OneOf is not the right tool for every situation. Understanding its limitations helps you make better design decisions.

No Pipeline Operators

Unlike Option<T> (which has Map, Bind, Filter, Zip, and more) and Result<T> (which has Map, Bind, Ensure), OneOf does not provide pipeline operators. There is no OneOf.Map that transforms one variant while leaving the others unchanged. There is no OneOf.Bind that chains union-returning functions.

This is intentional. Option<T> and Result<T> have two variants with a clear "success" path (Some and Success), which makes Map unambiguous: transform the success variant, propagate the failure variant. OneOf<T1, T2, T3> has three variants with no inherent "success" path. Which variant should Map transform? All three? Just one? The answer depends on the domain, and a generic Map method cannot know the domain.

If you need to transform one variant of a union, use Match:

// "Map T1, leave T2 and T3 unchanged"
OneOf<OrderDto, NotFoundError, ForbiddenError> mapped = result.Match(
    withT1: order => (OneOf<OrderDto, NotFoundError, ForbiddenError>)_mapper.Map(order),
    withT2: nf => nf,
    withT3: fb => fb
);

This is more verbose than result.Map(order => _mapper.Map(order)), but it is unambiguous: you can see exactly which variant is being transformed and which are passing through.

No Async Match

Match and Switch are synchronous. There is no MatchAsync that takes Func<T1, Task<TResult>> lambdas.

This is a pragmatic decision. Async lambdas in Match would require Task<TResult> return types, which means every branch must be async even if only one branch does async work. This leads to Task.FromResult wrappers on synchronous branches:

// Hypothetical async Match -- note the Task.FromResult noise
var result = await union.MatchAsync(
    withT1: async card => await _cardGateway.ChargeAsync(card),
    withT2: bank => Task.FromResult(ProcessBankSync(bank)),  // ugly
    withT3: crypto => Task.FromResult(ProcessCryptoSync(crypto))  // ugly
);

Instead, match synchronously and then await:

Task<Receipt> task = union.Match(
    withT1: card => _cardGateway.ChargeAsync(card),
    withT2: bank => _bankGateway.TransferAsync(bank),
    withT3: crypto => _cryptoGateway.SendAsync(crypto)
);

var receipt = await task;

If all branches return Task<T>, Match naturally produces a Task<T> that you can await.

Maximum Four Variants

As discussed, four is the maximum arity. If you genuinely need five or more, restructure your domain or use a different approach.


Summary

The Union pattern fills a gap in the C# type system. The language does not have discriminated unions, but the need for them appears everywhere: API responses with multiple outcome types, domain models with closed sets of alternatives, parsers that produce different token types, and service methods that can succeed or fail in type-distinct ways.

OneOf<T1, T2>, OneOf<T1, T2, T3>, and OneOf<T1, T2, T3, T4> provide the core guarantee: exhaustive matching. Every Match call handles every variant. Adding a variant breaks all callers at compile time. Removing a variant breaks all callers at compile time. The compiler enforces completeness, not the developer's memory.

The API is deliberately small. Factory methods for creation. Implicit conversions for ergonomics. IsT/AsT properties for conditional access. Match for exhaustive value computation. Switch for exhaustive side effects. TryGet<T> for focused extraction. IEquatable for value equality. That is the entire surface.

The implementation is straightforward. A byte discriminator. An object field. No inheritance. No interfaces beyond IEquatable. No dependencies. No runtime reflection. No source generation. Just a sealed class with private storage and public methods that enforce type safety through generic constraints and method signature arity.

When to reach for OneOf:

  • You have a closed set of 2 to 4 distinct types.
  • The consumer must handle every variant -- no default branch, no ignored cases.
  • The variants are not related by inheritance (if they are, use the inheritance).
  • The scenario is not "presence vs absence" (Option) or "success vs failure" (Result), but "one of N distinct alternatives."

When not to reach for OneOf:

  • You have more than four variants. Restructure.
  • You need pipeline operators (Map, Bind). Use Option or Result.
  • You are in a hot loop where a single heap allocation matters. Use a custom struct.
  • The variants form a natural inheritance hierarchy. Use records and switch.

Unions are a foundation. They appear in service interfaces, in controller actions, in command handlers, and in test assertions across the FrenchExDev ecosystem. They compose with Result<T> (union of outcomes wrapped in a result) and with Option<T> (an option that contains a union). They work with [Injectable] DI registration without special handling. They are testable with standard assertion libraries and with the UnionAssert helper shown above.

The next chapter covers the Guard pattern -- the third foundational type-safety tool in FrenchExDev's toolkit. Where Option eliminates null, and Union eliminates unchecked variant dispatch, Guard eliminates invalid input. Together, these three patterns form the defensive perimeter that protects your domain model from the outside world.


Next: Part IV: The Guard Pattern -- Three prongs of defensive programming: Guard.Against, Guard.ToResult, and Guard.Ensure.

Previous: Part II: The Option Pattern -- Option<T> as a sealed record, exhaustive matching, and the full extension landscape.

Series index: Nine Patterns, One Framework

⬇ Download