Unio: High-Performance Discriminated Unions for C#

C# is a powerful language, but there is one road it has not yet fully paved: native discriminated union types. Developers have been working around this absence for years, typically choosing between throwing exceptions for non-exceptional paths, returning nullable values, using out parameters, or layering custom wrapper classes on top of every service boundary. Each of these approaches carries a cost - in clarity, type safety, or runtime efficiency.

Unio is a new open-source library that fills this gap with a modern, high-performance design: a zero-allocation readonly struct core, a Roslyn incremental source generator for named union types and 39 ready-to-use sentinel and value-carrying types for common patterns.

What a Discriminated Union Is

A discriminated union is a type that holds exactly one value at a time, where that value is one of a fixed set of types and the type currently held is tracked explicitly. Access to the stored value requires checking which type is active - the union forces exhaustive handling rather than allowing silent misuse.

In languages like F# or Rust, discriminated unions are a first-class language feature. In C# they are a library concern, but a well-designed library can come very close to the native experience.

The Problem with Current Approaches

Consider a typical service method that fetches a user. It can succeed, return nothing because the record was not found, or fail because the caller lacks permission. In most C# codebases, this looks like one of the following:

 1// Option A: nullable return - caller must remember to check
 2User? GetUser(int id) { ... }
 3
 4// Option B: exception for a normal "not found" state - expensive and misleading
 5// No joke - this is the worst implementation possible, but still widely used...
 6User GetUser(int id)
 7{
 8    User? user = _repo.Find(id);
 9    if (user is null) throw new NotFoundException(id);
10    return user;
11}
12
13// Option C: a custom result class - works, but requires a new type per use case
14GetUserResult GetUser(int id) { ... }

None of these options tell the compiler - or the next developer - that there are exactly three possible outcomes and that all three must be handled. The type system offers no enforcement. Important branches slip through, null checks get forgotten and exceptions travel far outside their intended scope before being caught.

Existing libraries like OneOf addressed this gap years ago and brought discriminated unions to the C# ecosystem. However, their internal implementation relies on object boxing for value storage and a class-based core type, which adds GC pressure and makes equality semantics awkward on hot paths.

Unio takes the same concept and rebuilds it from the ground up with a readonly struct as its core type, typed generic fields that avoid object boxing entirely and an allocation-free matching API.

Fewer Developer Errors by Design

The most underappreciated benefit of discriminated unions is not performance - it is correctness. Implicit, convention-based result handling is one of the most persistent sources of bugs in production C# services and discriminated unions systematically eliminate the conditions that produce those bugs.

Logic hidden in exceptions

Exceptions are a control-flow mechanism designed for genuinely unexpected situations. Using them for expected outcomes - record not found, access denied, quota exceeded - couples the caller’s control flow to the exception type hierarchy. The method signature says nothing about these outcomes. A new team member, a reviewer, or an automated refactoring tool has no way to know from the signature alone that GetUser might throw NotFoundException unless they read the implementation or find documentation.

Not-found is not exceptional. Unauthorized is not exceptional. These are normal, expected states that a well-designed API should surface as values - not as jumps in the call stack. Discriminated unions make that explicit:

1// The signature itself is the documentation:
2// this method returns a User, or NotFound, or Unauthorized - nothing else.
3public GetUserResult GetUser(int id, ClaimsPrincipal caller) { ... }

When every outcome is a value in the return type, there is nothing hidden. No undocumented exception escaping up multiple stack frames, no caller silently swallowing a catch block, no try/catch wrapped around code that should simply check a condition.

Testability and completeness

Because exhaustive matching is enforced at compile time, tests naturally follow the same structure. Every test for a method that returns a union type must address every case - the compiler refuses to let Match compile with missing branches. This creates a direct feedback loop between implementation and test coverage that is impossible to replicate with nullable returns or exception-based control flow.

Consider a service method that can return three outcomes. A test suite for it naturally falls into three groups:

 1public sealed class GetUserTests
 2{
 3    [Fact]
 4    public void Returns_user_when_found_and_authorized()
 5    {
 6        GetUserResult result = _service.GetUser(knownId, adminClaims);
 7
 8        // The match forces handling all three cases, even in tests
 9        result.Switch(
10            user  => user.Id.Should().Be(knownId),
11            _     => throw new Exception("Expected user, got NotFound"),
12            _     => throw new Exception("Expected user, got Unauthorized"));
13    }
14
15    [Fact]
16    public void Returns_not_found_when_user_does_not_exist()
17    {
18        GetUserResult result = _service.GetUser(unknownId, adminClaims);
19        result.IsT1.Should().BeTrue();
20    }
21
22    [Fact]
23    public void Returns_unauthorized_when_caller_lacks_permission()
24    {
25        GetUserResult result = _service.GetUser(knownId, guestClaims);
26        result.IsT2.Should().BeTrue();
27    }
28}

The test structure mirrors the outcome structure of the method. All three paths are covered because the type system makes ignoring any of them actively difficult. With exception-based control flow, the NotFoundException path might not be tested at all - there is no compile-time feedback that it was omitted.

This also means that expanding a result type - adding a fourth outcome - immediately produces compile-time errors at every Match call site, including tests. The compiler drives the developer to update every consumer, making partial updates structurally impossible.

Introducing Unio

Unio comes as four focused packages:

  • Unio - the core library. Provides Unio<T0, T1> through Unio<T0, …, T19> as readonly struct, plus the UnioBase<…> abstract base class for named union types.
  • Unio.SourceGenerator - a Roslyn incremental source generator that turns a one-line declaration into a fully generated named union class.
  • Unio.Types - 39 pre-built sentinel and value-carrying types for common patterns (NotFound, Success, ValidationError and many more).
  • Unio.AspNetCore - a minimal extension that maps any union result to an IResult via a ToHttpResult() call.

Installation is straightforward:

1dotnet add package Unio
2dotnet add package Unio.SourceGenerator  # optional, for named union types
3dotnet add package Unio.Types            # optional, for pre-built types
4dotnet add package Unio.AspNetCore       # optional, for ASP.NET Core integration

Core Usage: Generic Union Types

The simplest form is the generic Unio<T0, T1>. Values are assigned via implicit conversion - the compiler picks the right slot automatically.

1using Unio;
2
3Unio<int, string> result = 42;
4Unio<int, string> error = "Something went wrong";

Matching is exhaustive. Every branch must be covered; missing one is a compile-time error.

1string message = result.Match(
2    value => $"Success: {value}",
3    err   => $"Error: {err}");

Switch works the same way but executes actions instead of returning a value:

1result.Switch(
2    value => Console.WriteLine($"Value: {value}"),
3    err   => Console.Error.WriteLine($"Error: {err}"));

For safe access without throwing, use the TryGet pattern. It follows the same idiom as TryParse and eliminates any risk of InvalidOperationException:

1if (result.TryGetT0(out int number))
2{
3    ProcessNumber(number);
4}
5else if (result.TryGetT1(out string text))
6{
7    ProcessText(text);
8}

Direct access via AsT0, AsT1 and so on is also available. The IsT0 / IsT1 boolean properties allow lightweight type checks without a full match:

1if (result.IsT0)
2    Console.WriteLine($"Got int: {result.AsT0}");

Named Union Types via Source Generator

Generic union types work well for local, contained use. At service boundaries, interface contracts and architectural layers, they become a liability: the full list of type parameters leaks into every signature that crosses the boundary. A method returning Unio<Order, NotFound, Unauthorized> forces every caller to know about all three types - and when a fourth outcome is added, every call site must change.

Named union types address this directly. The source generator turns a one-line declaration into a complete, sealed, strongly typed union class:

1using Unio;
2
3[GenerateUnio]
4public partial class GetOrderResult : UnioBase<Order, NotFound, Unauthorized>;

The Roslyn incremental generator runs entirely at compile time and emits a .g.cs file with the constructor, one implicit conversion operator per type and typed equality members. Everything else - Match, Switch, TryGet, MapT#, ValueOrT#, async variants, IFormattable, ISpanFormattable, IUtf8SpanFormattable - is inherited from UnioBase<…> and requires no repetition.

The result is a first-class named type that is indistinguishable from a hand-written class from the outside, while internally carrying full union semantics.

Clean Service Interfaces

The most immediate payoff of named result types is at the service interface level. Without them, the generic union bleeds into every interface signature:

1// The interface exposes all three type parameters as part of its contract
2public interface IOrderService
3{
4    Unio<Order, NotFound, Unauthorized> GetOrder(int id, ClaimsPrincipal caller);
5    Unio<Created<Order>, ValidationError, Conflict> CreateOrder(OrderRequest request);
6}

Any change to one of those type arguments - adding BadRequest, replacing Unauthorized with a richer error type - ripples through every implementing class and every mock or stub. The interface ceases to be a stable abstraction.

With named result types, the interface surface mirrors the domain:

 1[GenerateUnio]
 2public partial class GetOrderResult : UnioBase<Order, NotFound, Unauthorized>;
 3
 4[GenerateUnio]
 5public partial class CreateOrderResult : UnioBase<Created<Order>, ValidationError, Conflict>;
 6
 7public interface IOrderService
 8{
 9    GetOrderResult GetOrder(int id, ClaimsPrincipal caller);
10    CreateOrderResult CreateOrder(OrderRequest request);
11}

The union internals are an implementation detail of the result type, not of the interface. Implementations, mocks, test doubles and dependency injection registration all work with a simple, named class.

A concrete implementation illustrates how little ceremony is involved:

 1public sealed class OrderService : IOrderService
 2{
 3    private readonly IOrderRepository _repo;
 4    private readonly IAuthorizationService _auth;
 5
 6    public OrderService(IOrderRepository repo, IAuthorizationService auth)
 7    {
 8        _repo = repo;
 9        _auth = auth;
10    }
11
12    public GetOrderResult GetOrder(int id, ClaimsPrincipal caller)
13    {
14        if (!_auth.IsAllowed(caller, "orders.read"))
15            return new Unauthorized();
16
17        Order? order = _repo.Find(id);
18        return order is not null ? order : new NotFound();
19    }
20
21    public CreateOrderResult CreateOrder(OrderRequest request)
22    {
23        if (!request.IsValid(out string[] errors))
24            return new ValidationError<string[]>(errors);
25
26        if (_repo.ExistsByReference(request.Reference))
27            return new Conflict();
28
29        Order created = _repo.Insert(request);
30        return new Created<Order>(created);
31    }
32}

Registration in the DI container requires nothing special:

1builder.Services.AddScoped<IOrderService, OrderService>();

The named result types behave exactly like any other return type from the container’s perspective.

CQRS Query and Command Handlers

In a CQRS architecture, queries and commands are dispatched to dedicated handlers. Each handler has a fixed, well-defined return type. Named union types map onto this pattern precisely - the result type is declared once alongside the query or command and all downstream consumers work against it.

Without named types, the handler interface requires everyone to spell out the full generic:

1// The full generic union leaks into the dispatcher's type resolution
2public sealed class GetOrderQueryHandler : IQueryHandler<GetOrderQuery, Unio<Order, NotFound, Unauthorized>>
3{
4    public Unio<Order, NotFound, Unauthorized> Handle(GetOrderQuery query) { ... }
5}

Open-generic DI registration for IQueryHandler<,> works at the infrastructure level, but every endpoint, pipeline decorator and logging behavior must also carry or reconstruct those three type arguments. This is noise that accumulates at every layer.

With a named result type, each query/command pair forms a self-contained unit:

1// Query + result type declared together
2public sealed record GetOrderQuery(int OrderId, Guid CallerId);
3
4[GenerateUnio]
5public partial class GetOrderResult : UnioBase<Order, NotFound, Unauthorized>;

The handler becomes focused and readable:

 1public sealed class GetOrderQueryHandler : IQueryHandler<GetOrderQuery, GetOrderResult>
 2{
 3    private readonly IOrderRepository _repo;
 4    private readonly IAuthorizationService _auth;
 5
 6    public GetOrderQueryHandler(IOrderRepository repo, IAuthorizationService auth)
 7    {
 8        _repo = repo;
 9        _auth = auth;
10    }
11
12    public GetOrderResult Handle(GetOrderQuery query)
13    {
14        if (!_auth.CanRead(query.CallerId))
15            return new Unauthorized();
16
17        Order? order = _repo.Find(query.OrderId);
18        return order is not null ? order : new NotFound();
19    }
20}

Registration via DI is clean and follows standard patterns:

1builder.Services.AddScoped<IQueryHandler<GetOrderQuery, GetOrderResult>, GetOrderQueryHandler>();
2builder.Services.AddScoped<IQueryHandler<CreateOrderCommand, CreateOrderResult>, CreateOrderCommandHandler>();

The dispatcher resolves the right handler by its generic arguments. Because GetOrderResult is a concrete class - not a structural generic - this resolution is unambiguous and works without special conventions.

At the API boundary, the result type provides a complete, legible contract:

 1app.MapGet("/orders/{id:int}", (
 2    int id,
 3    ClaimsPrincipal caller,
 4    IQueryHandler<GetOrderQuery, GetOrderResult> handler) =>
 5{
 6    GetOrderResult result = handler.Handle(new GetOrderQuery(id, caller.GetUserId()));
 7
 8    return result.Match(
 9        order => Results.Ok(order),
10        _     => Results.NotFound(),
11        _     => Results.Unauthorized());
12});

The same discipline applies to command handlers. A CreateOrderCommandHandler returns CreateOrderResult, a DeleteOrderCommandHandler returns DeleteOrderResult and so on. Each result type documents the exact outcome space of its command - nothing inferred from nullable returns, nothing hidden in exception types, nothing relying on convention.

Pipeline Decorators and Cross-Cutting Concerns

Named result types also simplify cross-cutting concerns such as logging, caching and validation decorators. A decorator for IQueryHandler<TQuery, TResult> can inspect or transform results without needing to know the union structure - it just passes the result through for the caller to match.

When the decorator does need to inspect the result (for example, to log a NotFound outcome), it can do so through the declared result type:

 1public sealed class LoggingQueryHandlerDecorator<TQuery, TResult> : IQueryHandler<TQuery, TResult>
 2{
 3    private readonly IQueryHandler<TQuery, TResult> _inner;
 4    private readonly ILogger<LoggingQueryHandlerDecorator<TQuery, TResult>> _logger;
 5
 6    public LoggingQueryHandlerDecorator(
 7        IQueryHandler<TQuery, TResult> inner,
 8        ILogger<LoggingQueryHandlerDecorator<TQuery, TResult>> logger)
 9    {
10        _inner = inner;
11        _logger = logger;
12    }
13
14    public TResult Handle(TQuery query)
15    {
16        _logger.LogDebug("Handling {QueryType}", typeof(TQuery).Name);
17        TResult result = _inner.Handle(query);
18        _logger.LogDebug("Handled {QueryType}", typeof(TQuery).Name);
19        return result;
20    }
21}

The decorator is fully generic and remains decoupled from any specific result type. Individual handlers still get their strongly typed results - the decorator never needs to unwrap them.

Compile-Time Diagnostics

The source generator reports structural errors at compile time rather than at runtime:

 1// UNIO001: missing UnioBase<...> base - detected at compile time
 2[GenerateUnio]
 3public partial class Bad;
 4
 5// UNIO002: invalid arity - must be between 2 and 20
 6[GenerateUnio]
 7public partial class TooFew : UnioBase<int>;
 8
 9// UNIO003: duplicate type arguments - compiler warning
10[GenerateUnio]
11public partial class Duplicate : UnioBase<string, string>;
12
13// UNIO004: informational - union class should be sealed
14[GenerateUnio]
15public partial class NotSealed : UnioBase<string, int>;

Mistakes that would otherwise surface as runtime exceptions or subtle type mismatches are caught before the project compiles.

Pre-Built Types with Unio.Types

Many union use cases repeat the same semantic markers: something was not found, an operation succeeded, a conflict occurred. Writing these types from scratch every time adds noise without adding meaning.

Unio.Types ships 39 ready-made types across seven categories:

  • Boolean / Ternary: Yes, No, Maybe, True, False, Unknown
  • Collection: All, Some, None, Empty
  • State: Pending, Cancelled, Timeout, Skipped, Invalid, Disabled, Expired, RateLimited
  • HTTP / API: NotFound, Forbidden, Unauthorized, Conflict, BadRequest, Accepted, NoContent
  • CRUD: Created, Updated, Deleted, Unchanged
  • Result: Success, Error
  • Value Carriers: Success<T>, Error<T>, Result<T>, NotFound<T>, Created<T>, Updated<T>, ValidationError, ValidationError<T>

All marker types are readonly struct with IEquatable<T>, ==/!= operators and [AggressiveInlining] on all equality paths.

A complete CRUD example illustrates how these compose naturally:

 1using Unio;
 2using Unio.Types;
 3
 4public Unio<Created<int>, ValidationError, Conflict> CreateProduct(ProductDto dto)
 5{
 6    if (string.IsNullOrEmpty(dto.Name))
 7        return new ValidationError("Name is required");
 8
 9    if (_repo.ExistsByName(dto.Name))
10        return new Conflict();
11
12    int newId = _repo.Insert(dto);
13    return new Created<int>(newId);
14}
15
16Unio<Created<int>, ValidationError, Conflict> result = CreateProduct(dto);
17result.Switch(
18    created => Console.WriteLine($"Created with ID: {created.Value}"),
19    error   => Console.WriteLine($"Validation failed: {error.Message}"),
20    _       => Console.WriteLine("Product already exists"));

The return type of CreateProduct documents all outcomes explicitly. No comment needed, no convention to memorize - the type signature is the contract.

A state machine is equally expressive:

 1[GenerateUnio]
 2public partial class JobState : UnioBase<Pending, Success<JobResult>, Error<string>, Cancelled, Timeout>;
 3
 4JobState state = new Pending();
 5
 6// ... later, after processing:
 7state = new Success<JobResult>(result);
 8
 9Console.WriteLine(state.Match(
10    _       => "Pending",
11    success => $"Done: {success.Value}",
12    error   => $"Failed: {error.Value}",
13    _       => "Cancelled",
14    _       => "Timed out"));

Allocation-Free Matching with TState

One subtlety matters on hot paths: lambda captures. When a Match or Switch lambda captures a local variable, the compiler generates a new closure object on the heap every invocation. In tight loops or high-throughput request pipelines, that allocation accumulates.

Unio solves this with a TState overload on both Match and Switch. Instead of capturing variables in a closure, state is passed directly as a parameter to static lambdas:

 1string prefix = "Result";
 2
 3// Standard match - allocates a new closure object on every call
 4string standard = union.Match(
 5    i => $"{prefix}: {i}",
 6    s => $"{prefix}: {s}");
 7
 8// TState match - zero allocation; prefix is passed as a value parameter
 9string allocationFree = union.Match(prefix,
10    static (p, i) => $"{p}: {i}",
11    static (p, s) => $"{p}: {s}");

When multiple mutable targets are needed inside Switch<TState>, a ValueTuple bundles them without introducing a reference type:

1union.Switch((logger, config),
2    static (s, i)   => s.logger.LogInformation("int {V}", i),
3    static (s, str) => s.logger.LogDebug("string {V}", str));

Performance Design

The performance characteristics of Unio are the result of deliberate implementation choices rather than micro-optimizations applied after the fact.

The core Unio<T0, T1> type is a readonly struct. For small value types this means the union itself lives on the stack and no heap allocation occurs at creation. Value types are stored in typed generic fields (T0? _value0, T1? _value1) rather than a single object field - there is no boxing. A single byte _index field tracks which type is currently active.

[MethodImpl(MethodImplOptions.AggressiveInlining)] is applied to all property accessors, TryGet methods, Match, Switch and operators. The JIT sees through these calls and eliminates the call overhead entirely on hot paths. Match and Switch compile to switch expressions, which the JIT further optimizes to jump tables.

For named union types via source generator, UnioBase<…> is an abstract class, giving the generated type class semantics and identity semantics while still delegating all operations to the underlying Unio<…> struct.

Benchmark results on .NET 10 (AMD Ryzen 9 9950X) show the concrete impact:

ScenarioUnioOneOfAllocated
Match (2-arity)0.78 ns0.74 ns0 B
Match (5-arity)1.08 ns1.08 ns0 B
Match<TState> vs capturing lambda (2-arity)4.85 ns5.61 ns48 B vs 72 B
Switch<TState> vs capturing lambda (2-arity)0.69 ns1.95 ns0 B vs 32 B
ToString0.44 ns5.11 ns0 B vs 56 B
TryGetT0 (5-arity)0.00 ns2.65 ns0 B
TryGetT4 miss (5-arity)0.01 ns4.10 ns0 B

The Switch<TState> result is particularly clear: Unio eliminates the 32-byte closure allocation that every capturing lambda in OneOf creates, with a 2.8x throughput difference at 2-arity.

Discriminated Unions and AI-Assisted Development

There is a practical benefit to explicit, value-based result modeling that has become increasingly relevant: AI coding assistants and LLMs perform significantly better when working with discriminated unions than with implicit, exception-driven code.

The reason is straightforward. A language model generates code by reasoning about the types and structure it sees. When a method returns Unio<Order, NotFound, Unauthorized>, the model has the full outcome space directly in the signature. It knows without inference, convention lookup, or documentation that there are exactly three possible outcomes, what each one is and that all three must be handled. The context the model needs is already encoded in the type.

With implicit approaches, that context is not available in the signature. A nullable return says nothing about why the value might be absent. An exception says nothing about which exception types are expected versus which are failures. A custom result class might encode outcomes, but without exhaustive matching there is no structural feedback that all cases were considered. The model must guess, consult documentation it may not have, or generate incomplete handling.

In practice, this means:

  • A model working with GetOrderResult generates a Match call that covers all three branches on the first attempt, because the compiler rejects anything else.
  • When the developer adds a fourth outcome to the result type, the model immediately sees the compile error at every call site - including AI-generated call sites - and can fix them without a separate prompt cycle.
  • The model does not need to infer “this might return null” or “this might throw” from context. The answer is in the type.

Explicit outcome modeling is not just a benefit for human developers. Any tool that reads and generates code - static analyzers, refactoring engines, AI assistants - produces better results when the contract is fully expressed in the type system rather than spread across documentation, conventions and runtime behavior.

Advantages at a Glance

Type-safe exhaustive matching. Match and Switch reject incomplete handling at compile time. Every case must be covered; there is no silent fall-through.

No boxing of value types. Typed generic fields mean int, Guid, or any small struct is stored directly without wrapping in object. This matters both for memory and for GC pause latency.

Zero-allocation path. The TState overloads allow high-throughput code to avoid closure allocation entirely while preserving clean, readable match syntax.

Full value equality. IEquatable<T>, ==, != and GetHashCode work correctly and consistently across all union types, including inside Dictionary and HashSet.

Named types with one declaration. The source generator turns a partial class declaration into a complete, sealed, strongly typed union class. The union internals are encapsulated behind a meaningful name - services, interfaces, DI registrations and consumers all see a stable contract rather than a structural generic.

Stable interface contracts. A service interface returning GetOrderResult is a durable abstraction. Adding or changing a union type argument is a local change to a single type declaration, not a cascading update across every signature in the codebase.

Natural fit for CQRS. Query and command handlers return named result types that document the exact outcome space of each operation. Dispatcher and mediator registrations work unambiguously with concrete named types. Decorators and pipeline behaviors stay generic and decoupled.

No logic hidden in exceptions. Expected outcomes are values in the return type, not jumps in the call stack. A NotFound or Unauthorized result is documented in the method signature, not buried in a catch block somewhere up the call chain.

Structurally complete test coverage. Exhaustive matching at compile time means every test suite that uses Match or Switch must address every declared outcome. Adding a new outcome to a result type produces compile errors at all call sites - tests included - making incomplete updates structurally impossible.

Better results with AI coding assistants. When the full outcome space is encoded in the type, a language model sees exactly what outcomes are possible and generates exhaustive handling from the first attempt. Compile errors from incomplete matches propagate to AI-generated code the same way they do to hand-written code - closing the feedback loop without additional prompts.

Rich pre-built types. 39 ready-made types cover the vocabulary of most API and domain layers. Less boilerplate, clearer intent.

Modern target frameworks. Unio targets .NET 8, .NET 9 and .NET 10. There is no netstandard2.0 compatibility shim, which means the implementation can take advantage of current runtime capabilities without compromise.


Unio is available on NuGet and the source is open on GitHub at github.com/BenjaminAbt/Unio under the MIT license.


Let's Work Together

Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization, or building new solutions from scratch - I'm here to help you succeed.

New Platforms
Modernization
Training & Consulting

Comments

Twitter Facebook LinkedIn WhatsApp