UnionRailway 1.2.2

dotnet add package UnionRailway --version 1.2.2
                    
NuGet\Install-Package UnionRailway -Version 1.2.2
                    
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.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="UnionRailway" Version="1.2.2" />
                    
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.2
                    
#r "nuget: UnionRailway, 1.2.2"
                    
#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.2
                    
#: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.2
                    
Install as a Cake Addin
#tool nuget:?package=UnionRailway&version=1.2.2
                    
Install as a Cake Tool

UnionRailway

<div align="center">

Build Tests NuGet Downloads License: MIT .NET

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

Quick Start · Why This? · Benchmarks · Full Docs · Website

</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();
Working with Anonymous Types & Object Results

When returning object (e.g., anonymous types for DTOs), use BindObject to avoid generic type inference issues:

// ✅ GOOD: Use BindObject for object/anonymous type results
var result = await GetProductAsync(id)
    .BindObject(
        product => product.Stock > 0,
        product => new { product.Id, product.Name, Status = "In Stock" },
        product => new UnionError.Conflict("Out of stock"));

// ✅ GOOD: Classic style also works
var result = await GetProductAsync(id)
    .BindObject(product => product.Stock > 0
        ? Union.Ok(new { product.Id, product.Name })  // Union.Ok(object) overload
        : Union.Fail<object>(new UnionError.Conflict("Out of stock")));

// ❌ AVOID: Generic inference issues with Bind<T, object>
var result = await GetProductAsync(id)
    .Bind(product => Union.Ok(new { product.Id }));  // May cause type issues!

Why BindObject?

  • Prevents C# generic type inference issues with anonymous types
  • Predicate-style syntax is more readable for conditional logic
  • Explicitly signals that boxing to object will occur

When to use what:

  • Use Map when transforming the success value (no error path)
  • Use Bind when chaining operations that return Rail<T> (with explicit types)
  • Use BindObject when returning object or anonymous types with conditional errors
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();

Ensure — Guard Values in the Chain

Ensure validates the success value against a predicate. If it fails, the rail short-circuits to an error — preventing null or invalid values from reaching downstream Bind/Map calls:

var result = await GetTransactionAsync(id)
    .EnsureAsync(
        t => t is not null,
        _ => new UnionError.NotFound("Transaction"))
    .BindAsync(async t =>
    {
        t.Items.ForEach(i => i.Issued = true);
        return await SaveAsync(t);
    });
// If GetTransactionAsync returns Ok(null), Ensure converts it to NotFound
// and BindAsync is never called.

ToFail — Shorthand Error Creation

Create failed rails directly from any UnionError:

// Before:
return Union.Fail<Order>(new UnionError.Conflict("duplicate"));

// After:
UnionError error = new UnionError.Conflict("duplicate");
return error.ToFail<Order>();

SystemFailure — Message Constructor

Create system failures without wrapping in an exception:

// Message only (wraps in InvalidOperationException internally):
var sf = new UnionError.SystemFailure("Database connection lost");

// Message with inner exception:
var sf = new UnionError.SystemFailure("Operation failed", innerException);

Switch — Void Match for Side Effects

Switch is the void counterpart of Match — execute side effects on both success and error branches without needing a return value:

result.Switch(
    onOk: user => logger.LogInformation("Found user {Id}", user.Id),
    onError: err => logger.LogWarning("Failed: {Error}", err));

// Async version:
await resultTask.SwitchAsync(
    onOk: user => { logger.LogInformation("Found user {Id}", user.Id); return Task.CompletedTask; },
    onError: err => { logger.LogWarning("Failed: {Error}", err); return Task.CompletedTask; });

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: Native C# Unions

UnionRailway is architected for seamless migration to native C# unions when .NET 11 stable is released:

Current Implementation (.NET 8 & 11 Preview):

[Union]  // High-performance struct-based implementation
public readonly struct Rail<T> { /* ... */ }
public readonly struct UnionError { /* ... */ }

Future (.NET 11 Stable Release):

public union Rail<T>(T, UnionError);      // Native union types
public union UnionError(...);             // Native discriminated union

Your Application Code: Zero changes required! 🎉

The library is designed to automatically switch to native unions when:

  • .NET 11 stable is released with finalized union support
  • The union feature is production-ready (expected in .NET 11 RTM)

Until then, UnionRailway uses a highly optimized struct-based implementation that provides:

  • ✅ 0.5ns success path performance
  • ✅ Zero heap allocations
  • ✅ Full type safety
  • ✅ Compatible API surface with future native unions

Note: Union types are currently available in .NET 11 preview builds but are not yet enabled in this library until the feature stabilizes in the final release.

Target Frameworks: .NET 8.0, .NET 11.0 (and future versions)


💡 Best Practices & Common Pitfalls

✅ Do's

1. Use BindObject for Anonymous Types / object Results

// ✅ GOOD: Type-safe and clear
.BindObject(
    product => product.Stock > 0,
    product => new { product.Id, product.Name, Status = "Available" },
    product => new UnionError.Conflict("Out of stock"))

2. Use Union.Ok(object) When Boxing to Object

// ✅ GOOD: Explicit object overload prevents inference issues
Rail<object> result = Union.Ok(new { Id = 1, Name = "Test" });

3. Explicit Generic Parameters When Mixing Types

// ✅ GOOD: Clear and type-safe
.Bind<Product, OrderDto>(product => CreateOrderAsync(product))

4. Use ValueTask<Rail<T>> for Async Methods

// ✅ GOOD: Zero allocation on cached results
public async ValueTask<Rail<User>> GetUserAsync(int id)
{
    var user = await db.Users.FindAsync(id);
    return user ?? new UnionError.NotFound("User");
}

5. Chain Operations for Readability

// ✅ GOOD: Railway-style composition
var result = await GetProductAsync(id)
    .BindAsync(product => ValidateStockAsync(product))
    .MapAsync(product => new ProductDto(product))
    .ToHttpResultAsync();

❌ Don'ts

1. Don't Use Generic Bind with Anonymous Types

// ❌ BAD: Generic type inference issues!
.Bind(p => Union.Ok(new { p.Id, p.Name }))
// May serialize as Rail wrapper instead of the object!

// ✅ GOOD: Use BindObject instead
.BindObject(p => new { p.Id, p.Name })

2. Don't Mix Error Handling Styles

// ❌ BAD: Mixing exceptions with Railway
public async ValueTask<Rail<User>> GetUserAsync(int id)
{
    try
    {
        var user = await db.Users.FindAsync(id);
        return user ?? throw new NotFoundException(); // ❌ Don't throw!
    }
    catch (Exception ex)
    {
        return new UnionError.SystemFailure(ex);
    }
}

// ✅ GOOD: Pure Railway style
public async ValueTask<Rail<User>> GetUserAsync(int id)
{
    var user = await db.Users.FindAsync(id);
    return user ?? new UnionError.NotFound("User");
}

3. Don't Ignore Error Cases

// ❌ BAD: Assuming success without checking
var user = result.Unwrap(); // May throw UnwrapException!

// ✅ GOOD: Check first or use Match
if (result.IsSuccess(out var user, out var error))
{
    // use user
}
else
{
    // handle error
}

// OR use Match
var message = result.Match(
    onSuccess: user => $"Hello {user.Name}",
    onError: error => $"Error: {error}");

4. Don't Forget to await Async Chains

// ❌ BAD: Returning Task<Rail<T>> instead of Rail<T>
public async Task<IResult> GetUser(int id, UserService svc)
{
    var result = svc.GetUserAsync(id); // Missing await!
    return result.ToHttpResult(); // Won't compile!
}

// ✅ GOOD
public async Task<IResult> GetUser(int id, UserService svc)
{
    var result = await svc.GetUserAsync(id);
    return result.ToHttpResult();
}

// ✅ EVEN BETTER: Use ToHttpResultAsync
public async Task<IResult> GetUser(int id, UserService svc)
{
    return await svc.GetUserAsync(id).ToHttpResultAsync();
}

5. Don't Over-Use object as Return Type

// ❌ BAD: Loses type safety
public ValueTask<Rail<object>> GetUserAsync(int id)
{
    return db.Users.FirstOrDefaultAsUnionAsync("User", x => x.Id == id)
        .MapAsync(user => (object)user); // Why box?
}

// ✅ GOOD: Keep strong typing as long as possible
public ValueTask<Rail<User>> GetUserAsync(int id)
{
    return db.Users.FirstOrDefaultAsUnionAsync("User", x => x.Id == id);
}

// Use object only at HTTP boundary for DTOs:
.MapAsync(user => new { user.Id, user.Name })
.ToHttpResultAsync();

🎯 Performance Tips

  • ✅ Use ValueTask<Rail<T>> instead of Task<Rail<T>> for hot paths
  • ✅ Prefer MapAsync / BindAsync over synchronous versions when awaiting
  • ✅ Use Rail<Unit> for void-like operations (0 allocation)
  • ✅ Avoid boxing to object unless necessary (DTOs at HTTP layer)
  • ✅ Use AggressiveInlining extension methods for custom operators

🎓 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 · Website · 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.  net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net11.0

    • No dependencies.
  • net8.0

    • No dependencies.

NuGet packages (3)

Showing the top 3 NuGet packages that depend on UnionRailway:

Package Downloads
UnionRailway.HttpClient

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

UnionRailway.AspNetCore

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

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.2 227 7/21/2026
1.2.1 1,562 5/5/2026
1.2.0 192 4/29/2026
1.1.0 172 4/25/2026
1.0.0 167 4/6/2026