UnionRailway 1.2.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package UnionRailway --version 1.2.0
                    
NuGet\Install-Package UnionRailway -Version 1.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="UnionRailway" Version="1.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="UnionRailway" Version="1.2.0" />
                    
Directory.Packages.props
<PackageReference Include="UnionRailway" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add UnionRailway --version 1.2.0
                    
#r "nuget: UnionRailway, 1.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package UnionRailway@1.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=UnionRailway&version=1.2.0
                    
Install as a Cake Addin
#tool nuget:?package=UnionRailway&version=1.2.0
                    
Install as a Cake Tool

UnionRailway

<div align="center">

Build NuGet Downloads License: MIT .NET

Type-safe error handling for C# that actually feels native.

Quick Start ยท Why This? ยท Benchmarks ยท Full Docs

</div>


๐ŸŽฏ The Problem

Your error handling probably looks like this:

public async Task<IResult> GetUser(int id)
{
    try
    {
        var user = await db.Users.FindAsync(id);
        if (user == null)
            return Results.NotFound();

        return Results.Ok(user);
    }
    catch (UnauthorizedAccessException)
    {
        return Results.Unauthorized();
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Failed to get user");
        return Results.Problem("Something went wrong");
    }
}

Problems:

  • โŒ Exceptions for control flow (slow, unclear)
  • โŒ No type safety (caller doesn't know what errors to expect)
  • โŒ Inconsistent error responses across APIs
  • โŒ Manual mapping everywhere

โœจ The Solution

With UnionRailway, it becomes:

public async ValueTask<Rail<User>> GetUserAsync(int id)
{
    return await db.Users.FirstOrDefaultAsUnionAsync("User", x => x.Id == id);
}

// In your endpoint:
app.MapGet("/users/{id:int}", async (int id, UserService service) =>
{
    var result = await service.GetUserAsync(id);
    return result.ToHttpResult(); // โœ… Automatic RFC 7807 Problem Details
});

Benefits:

  • โœ… Type-safe - Compiler knows all possible errors
  • โœ… Zero boilerplate - One line to return RFC 7807 responses
  • โœ… Fast - 0.5ns success path, zero allocations
  • โœ… Consistent - Same error vocabulary everywhere (DB โ†’ Service โ†’ HTTP)

๐Ÿš€ Quick Start (2 Minutes)

1. Install

dotnet add package UnionRailway
dotnet add package UnionRailway.AspNetCore
dotnet add package UnionRailway.EntityFrameworkCore  # Optional

2. Return Rail<T> from your services

public class UserService
{
    public async ValueTask<Rail<User>> GetUserAsync(int id)
    {
        var user = await db.Users.FindAsync(id);
        if (user == null)
            return new UnionError.NotFound("User");

        return user; // โœ… Implicit conversion
    }
}

3. Convert to HTTP responses

app.MapGet("/users/{id}", async (int id, UserService service) =>
    (await service.GetUserAsync(id)).ToHttpResult());

Or even simpler with the endpoint filter โ€” no .ToHttpResult() needed:

var api = app.MapGroup("/api").WithRailwayFilter();

api.MapGet("/users/{id}", async (int id, UserService svc) =>
    await svc.GetUserAsync(id));  // Returns Rail<User> directly!

That's it! You now have:

  • โœ… Automatic 404 for NotFound
  • โœ… RFC 7807 Problem Details
  • โœ… OpenAPI documentation
  • โœ… Type-safe error handling

๐ŸŽญ Why UnionRailway?

vs. Exceptions

// โŒ Exceptions: Slow, unclear, unsafe
try { var user = await repo.GetAsync(id); }
catch (NotFoundException) { return Results.NotFound(); }
catch (UnauthorizedException) { return Results.Unauthorized(); }
// What other exceptions can this throw? ๐Ÿคท

// โœ… UnionRailway: Fast, explicit, type-safe
var result = await repo.GetAsync(id);
return result.ToHttpResult();
// Compiler knows: Success | NotFound | Unauthorized โœ…

vs. ErrorOr / LanguageExt

// โŒ Other libraries: Manual mapping, no ecosystem
ErrorOr<User> result = await repo.GetAsync(id);
return result.Match(
    value => Results.Ok(value),
    errors => errors[0].Type switch {
        ErrorType.NotFound => Results.NotFound(),
        ErrorType.Unauthorized => Results.Unauthorized(),
        _ => Results.Problem() // ๐Ÿ˜ฐ Manual everywhere
    });

// โœ… UnionRailway: Automatic RFC 7807, built-in integrations
var result = await repo.GetAsync(id);
return result.ToHttpResult(); // โœจ Done!

๐Ÿ“Š Full comparison with LanguageExt, ErrorOr, OneOf, FluentResults โ†’


๐ŸŽ What You Get

1. Semantic Error Types (not strings)

UnionError error = result.Error.GetValueOrDefault();

var message = error.Value switch
{
    UnionError.NotFound nf      => $"Missing: {nf.Resource}",
    UnionError.Conflict c       => $"Conflict: {c.Reason}",
    UnionError.Unauthorized     => "Authentication required",
    UnionError.Forbidden f      => $"Forbidden: {f.Reason}",
    UnionError.Validation v     => $"{v.Fields.Count} validation errors",
    UnionError.SystemFailure sf => sf.Ex.Message,
    UnionError.Custom c         => $"{c.Code}: {c.Message}",
    _                           => "Unknown"
};
Custom Domain Errors

Don't let the predefined categories limit you. UnionError.Custom lets you represent application-specific error conditions with a machine-readable code, custom HTTP status, and optional metadata:

// Return a domain-specific error
return new UnionError.Custom(
    Code: "RATE_LIMIT_EXCEEDED",
    Message: "Too many requests, please retry later.",
    StatusCode: 429,
    Extensions: new Dictionary<string, object>
    {
        ["retryAfter"] = 30,
        ["limit"] = 100
    });

// Maps to ProblemDetails with errorCode extension and your status code

2. Railway Composition

var result = await GetUserAsync(id)
    .BindAsync(user => GetOrdersAsync(user.Id))
    .MapAsync(orders => new OrderSummary(orders))
    .ToHttpResultAsync();
Recovery & Side Effects
// Recover from specific errors with a fallback
var user = await GetUserAsync(id)
    .RecoverAsync<User, UnionError.NotFound>(_ => guestUser);

// Execute side effects without changing the value
var result = await GetUserAsync(id)
    .TapAsync(user => LogAccessAsync(user.Id))
    .ToHttpResultAsync();

3. Ecosystem Integration

Package What It Does
UnionRailway Core Rail<T> and UnionError types
UnionRailway.AspNetCore RFC 7807 Problem Details mapping
UnionRailway.AspNetCore.OpenApi Automatic Swagger/OpenAPI docs
UnionRailway.EntityFrameworkCore FirstOrDefaultAsUnionAsync helpers
UnionRailway.HttpClient HTTP status โ†’ Rail<T> conversion

4. Migration-Friendly

// Wrap legacy exception-based code:
var result = await UnionWrapper.RunAsync(() => legacyService.LoadAsync());

// Wrap nullable returns:
var maybeUser = await UnionWrapper.RunNullableAsync(() => repo.FindAsync(id));

โšก Performance

Real benchmarks (BenchmarkDotNet, .NET 8):

Operation Time Allocated Notes
Create success 0.5 ns 0 B Stack-only, zero allocation
Create failure 2.8 ns 24 B Error record allocation
Create Custom error ~3.5 ns 32 B Record with optional extensions
Map operation 1.3 ns 0 B AggressiveInlining
Bind operation 36 ns 40 B Function call overhead
Recover (matching) ~2 ns 0 B Single is type check
Recover (non-matching) ~1 ns 0 B Short-circuit, no work
MapAsync 48 ns 0 B ValueTask overhead only
Service chain (3 ops) 20 ns 120 B Real-world scenario

Why so fast?

  • โœ… Struct-based Rail<T> lives on stack
  • โœ… Zero allocations on success path
  • โœ… ValueTask for async (vs Task)
  • โœ… AggressiveInlining on hot paths
  • โœ… configureProblem callback is null-guarded โ€” zero overhead when unused
  • โœ… Custom.Extensions is null by default โ€” pay only when you use it

Run yourself:

cd tests/UnionRailway.Benchmarks
dotnet run -c Release

๐Ÿ“– Complete Guide

Rail<T> - The Core Type

Represents exactly one of:

  • โœ… Success value of type T
  • โŒ UnionError
public ValueTask<Rail<User>> GetUserAsync(int id)
{
    // Option 1: Implicit conversion
    return user;

    // Option 2: Explicit creation
    return Union.Ok(user);
    return Union.Fail<User>(new UnionError.NotFound("User"));
}

Pattern Matching

// Style 1: IsSuccess pattern
if (!result.IsSuccess(out var user, out var error))
    return error.GetValueOrDefault().ToHttpResult();

// Style 2: Match
return result.Match(
    onOk: user => Results.Ok(user),
    onError: error => error.ToHttpResult());

// Style 3: Error property check
if (result.Error is not null)
    return result.Error.GetValueOrDefault().ToHttpResult();

Railway Composition

// Sync
var result = Union.Ok(5)
    .Map(x => x * 2)           // Transform success
    .Bind(x => ValidateAsync(x)); // Chain operations

// Async
var result = await GetUserAsync(id)
    .BindAsync(user => GetOrdersAsync(user.Id))
    .MapAsync(orders => orders.Count)
    .ToHttpResultAsync();

ASP.NET Core Integration

app.MapGet("/users/{id:int}", async (int id, UserService service) =>
    (await service.GetUserAsync(id)).ToHttpResult());

app.MapPost("/users", async (CreateUserRequest req, UserService service) =>
    (await service.CreateAsync(req)).ToHttpResult(createdUri: $"/users/{id}"))
    .WithCreatedRailOpenApi<RouteHandlerBuilder, UserDto>();

// Rail<Unit> automatically returns 204 No Content
app.MapDelete("/users/{id}", async (int id, UserService service) =>
    (await service.DeleteAsync(id)).ToHttpResult());
Customizing ProblemDetails

Post-process any ProblemDetails response to add trace IDs, strip details in production, or enrich extensions:

return result.ToHttpResult(configureProblem: pd =>
{
    pd.Extensions["traceId"] = Activity.Current?.Id;
    if (env.IsProduction())
        pd.Detail = "An error occurred.";
});
Custom Error Mapping (IUnionErrorMapper)

For full control over how errors translate to HTTP responses, implement IUnionErrorMapper and register it in DI. When TryMap returns non-null, that result is used directly; when it returns null, the default RFC 7807 mapping kicks in:

public class CustomErrorMapper : IUnionErrorMapper
{
    public IResult? TryMap(UnionError error) => error.Value switch
    {
        UnionError.NotFound nf => Results.Problem(
            detail: $"We could not locate '{nf.Resource}'.",
            statusCode: 404,
            title: "Resource Not Found"),
        UnionError.Custom { Code: "RATE_LIMIT" } c => Results.Problem(
            detail: c.Message,
            statusCode: 429,
            title: "Rate Limited"),
        _ => null // fall back to default mapping
    };
}

// Registration:
builder.Services.AddSingleton<IUnionErrorMapper, CustomErrorMapper>();

// Usage (inject via DI or pass directly):
return result.ToHttpResult(errorMapper: mapper);
Zero-Boilerplate with RailEndpointFilter

Instead of calling .ToHttpResult() on every endpoint, add the filter once and return Rail<T> directly from your handlers:

// Option 1: Per-group (recommended)
var api = app.MapGroup("/api").WithRailwayFilter();

api.MapGet("/users/{id}", async (int id, UserService svc) =>
    await svc.GetUserAsync(id));  // Rail<User> โ†’ 200/404 automatically

api.MapDelete("/users/{id}", async (int id, UserService svc) =>
    await svc.DeleteAsync(id));   // Rail<Unit> โ†’ 204 automatically

// Option 2: Per-endpoint
app.MapGet("/users/{id}", handler).WithRailwayFilter();

The filter resolves IUnionErrorMapper and RailwayOptions from DI automatically.

Global Exception Handling

Catch unhandled exceptions across all endpoints and return consistent RFC 7807 responses โ€” matching UnionRailway's error format:

var app = builder.Build();
app.UseRailwayExceptionHandler(); // Add early in pipeline
app.UseAuthentication();
app.UseAuthorization();
AddRailway() โ€” One-line DI Setup

Register all UnionRailway services with a single call:

builder.Services.AddRailway(options =>
{
    // Global ProblemDetails enrichment (applied to ALL error responses)
    options.ConfigureProblem = pd =>
        pd.Extensions["traceId"] = Activity.Current?.Id;
});

// Or with a custom mapper:
builder.Services.AddRailway<CustomErrorMapper>(options => ...);

Entity Framework Core

// FirstOrDefault โ†’ Rail<T>
var user = await db.Users.FirstOrDefaultAsUnionAsync("User", x => x.Id == id);

// SaveChanges โ†’ Rail<int>
var result = await db.SaveChangesAsUnionAsync();

HttpClient

var result = await httpClient.GetFromJsonAsUnionAsync<UserDto>("/users/42");
// Automatic mapping: 2xx โ†’ Success, 4xx/5xx โ†’ Error

๐Ÿ”ฎ Future: .NET 11 Native Unions

UnionRailway is designed for zero breaking changes when C# gets native unions:

Today (.NET 8):

[Union]  // Struct-based polyfill
public readonly struct Rail<T> { /* ... */ }

Tomorrow (.NET 11):

public union Rail<T>(T, UnionError);  // Native!

Your code: No changes needed! ๐ŸŽ‰


๐ŸŽ“ Learn More


๐Ÿ“ฆ Packages

Package Description Version Downloads
UnionRailway Core types and railway operators NuGet Downloads
UnionRailway.AspNetCore RFC 7807 Problem Details mapping NuGet Downloads
UnionRailway.AspNetCore.OpenApi Swagger/OpenAPI metadata NuGet Downloads
UnionRailway.EntityFrameworkCore EF Core extensions NuGet Downloads
UnionRailway.HttpClient HttpClient extensions NuGet Downloads

โค๏ธ Show Your Support

If UnionRailway helps your project, consider:

  • โญ Star this repo on GitHub
  • ๐Ÿ“ข Share with your team
  • ๐Ÿ› Report issues or suggest features
  • ๐Ÿค Contribute - PRs welcome!

๐Ÿ“„ License

MIT License - see LICENSE for details.


<div align="center">

Built with โค๏ธ for the C# community

Get Started ยท Documentation ยท GitHub

</div>

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages (3)

Showing the top 3 NuGet packages that depend on UnionRailway:

Package Downloads
UnionRailway.AspNetCore

ASP.NET Core integration for UnionRailway with RFC 7807 ProblemDetails mapping for Rail<T> and UnionError.

UnionRailway.HttpClient

HttpClient integration for UnionRailway that maps HTTP status codes and RFC 7807 payloads into Rail<T> results.

UnionRailway.EntityFrameworkCore

Entity Framework Core integration for UnionRailway with typed query and persistence results built on Rail<T> and UnionError.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.2.1 158 5/5/2026
1.2.0 182 4/29/2026
1.1.0 161 4/25/2026
1.0.0 153 4/6/2026