๐Ÿš‚

UnionRailway

Type-safe error handling for C# that actually feels native.
Zero boilerplate. RFC 7807 Problem Details. Built-in ecosystem integration.

Build NuGet Downloads License .NET
$ dotnet add package UnionRailway click to copy
The Problem

Exception-based error handling is broken

Exceptions are slow, invisible to callers, and create inconsistent API responses. There's a better way.

โŒ Without UnionRailway

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");
        return Results.Problem("Something went wrong");
    }
}
  • Exceptions for control flow (slow)
  • Caller doesn't know what errors to expect
  • Inconsistent error shapes across endpoints
  • Manual mapping everywhere

โœ… With UnionRailway

// Service method โ€” one line
public async ValueTask<Rail<User>> GetUserAsync(int id)
    => await db.Users
         .FirstOrDefaultAsUnionAsync("User", x => x.Id == id);

// Minimal API endpoint โ€” one line
app.MapGet("/users/{id:int}",
    async (int id, UserService svc) =>
        (await svc.GetUserAsync(id)).ToHttpResult());

// Or zero-boilerplate with the endpoint filter:
var api = app.MapGroup("/api").WithRailwayFilter();

api.MapGet("/users/{id}",
    async (int id, UserService svc) =>
        await svc.GetUserAsync(id)); // Rail<User> returned directly!
  • Compiler-enforced error handling
  • RFC 7807 Problem Details โ€” automatic
  • Consistent vocabulary from DB โ†’ HTTP
  • Zero allocations on the success path
Features

Everything you need, nothing you don't

UnionRailway is focused. ~15 core methods, deep ecosystem integration, and a clean learning curve.

๐ŸŽฏ

Struct-based Rail<T>

Lives on the stack. Zero allocations on the happy path. The success value is always 0.5 ns away.

๐Ÿ”’

Closed union errors

Seven semantic error variants (NotFound, Conflict, Unauthorized, Forbidden, Validation, SystemFailure, Custom) โ€” no string soup.

โšก

RFC 7807 Problem Details

One call to .ToHttpResult() produces standards-compliant Problem Details responses. No manual mapping.

๐Ÿ›ค๏ธ

Railway composition

Map, Bind, Tap, Recover โ€” chain operations while short-circuiting on the first error.

๐ŸŒ

ASP.NET Core integration

Endpoint filter, global exception handler, AddRailway() DI setup, and OpenAPI metadata โ€” all built-in.

๐Ÿ—„๏ธ

EF Core helpers

FirstOrDefaultAsUnionAsync and SaveChangesAsUnionAsync replace boilerplate null checks and try-catch.

๐ŸŒ

HttpClient helpers

GetFromJsonAsUnionAsync automatically maps 4xx/5xx status codes to typed UnionError variants.

๐Ÿ”„

Migration wrappers

UnionWrapper.RunAsync and RunNullableAsync wrap legacy exception-based or nullable code without refactoring.

๐Ÿ”ฎ

.NET 11 union-ready

The struct polyfill is designed as a drop-in for native C# union types โ€” aiming for minimal breaking changes when they ship.

Code Examples

See it in action

From the core types to ASP.NET Core, EF Core, and HttpClient โ€” one pattern, everywhere.

// 1. Return Rail<T> from your service methods
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");  // explicit error

        return user;  // implicit conversion โœ…
    }

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

        db.Users.Remove(user);
        await db.SaveChangesAsync();
        return Unit.Value; // 204 No Content
    }
}

// 2. Pattern matching โ€” choose your style
Rail<User> result = await service.GetUserAsync(id);

// Style A: IsSuccess out-parameter (early return, recommended)
if (!result.IsSuccess(out var user, out var error))
    return error.GetValueOrDefault().ToHttpResult();
Console.WriteLine(user.Name);

// Style B: Match
return result.Match(
    onOk:    u   => Results.Ok(u),
    onError: err => err.ToHttpResult());

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

// 3. Explicit helpers
var ok   = Union.Ok(new User { Id = 1 });
var fail = Union.Fail<User>(new UnionError.NotFound("User"));
// One-line DI setup
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>();

// Global exception middleware (add early in pipeline)
app.UseRailwayExceptionHandler();

// โ”€โ”€ Option A: .ToHttpResult() per endpoint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.MapGet("/users/{id:int}", async (int id, UserService svc) =>
    (await svc.GetUserAsync(id)).ToHttpResult());

// POST: returns 201 Created with Location header
app.MapPost("/users", async (CreateUserRequest req, UserService svc) =>
    (await svc.CreateAsync(req)).ToHttpResult(createdUri: $"/users/{req.Id}"))
    .WithCreatedRailOpenApi<RouteHandlerBuilder, UserDto>();

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

// Inline ProblemDetails customisation
return result.ToHttpResult(configureProblem: pd =>
{
    pd.Extensions["traceId"] = Activity.Current?.Id;
    if (env.IsProduction())
        pd.Detail = "An error occurred.";
});

// โ”€โ”€ Option B: Zero-boilerplate endpoint filter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
var api = app.MapGroup("/api").WithRailwayFilter();

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

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

// โ”€โ”€ Option C: Custom error mapper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
public class CustomErrorMapper : IUnionErrorMapper
{
    public IResult? TryMap(UnionError error) => error.Value switch
    {
        UnionError.NotFound nf => Results.Problem(
            detail: $"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 RFC 7807 mapping
    };
}
// Install: dotnet add package UnionRailway.EntityFrameworkCore

// โ”€โ”€ FirstOrDefaultAsUnionAsync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Replaces: var user = await db.Users.FirstOrDefaultAsync(...);
//           if (user == null) return NotFound();

Rail<User> user = await db.Users
    .FirstOrDefaultAsUnionAsync("User", x => x.Id == id);
// โœ… Returns NotFound("User") automatically when null

// With cancellation token
Rail<User> user = await db.Users
    .FirstOrDefaultAsUnionAsync("User", x => x.Id == id, cancellationToken);

// โ”€โ”€ SaveChangesAsUnionAsync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Returns Rail<int> (rows affected), wraps DbUpdateException โ†’ SystemFailure

Rail<int> saved = await db.SaveChangesAsUnionAsync();
if (!saved.IsSuccess(out var rows, out var err))
    return err.GetValueOrDefault().ToHttpResult();

Console.WriteLine($"{rows} row(s) saved.");

// โ”€โ”€ Full service example โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
public class ProductService(AppDbContext db)
{
    public async ValueTask<Rail<Product>> GetProductAsync(int id) =>
        await db.Products.FirstOrDefaultAsUnionAsync("Product", p => p.Id == id);

    public async ValueTask<Rail<Unit>> UpdateProductAsync(int id, UpdateDto dto)
    {
        var product = await db.Products.FirstOrDefaultAsUnionAsync("Product", p => p.Id == id);
        if (!product.IsSuccess(out var p, out var err))
            return err.GetValueOrDefault();

        p.Name = dto.Name;
        p.Price = dto.Price;
        var saved = await db.SaveChangesAsUnionAsync();
        return saved.IsSuccess(out _, out var saveErr) ? Unit.Value : saveErr.GetValueOrDefault();
    }
}
// Install: dotnet add package UnionRailway.HttpClient

// โ”€โ”€ GET โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Rail<UserDto> result = await httpClient
    .GetFromJsonAsUnionAsync<UserDto>("/users/42");

// Automatic mapping:
//   2xx  โ†’ Rail<T> with the deserialized body
//   404  โ†’ UnionError.NotFound
//   401  โ†’ UnionError.Unauthorized
//   403  โ†’ UnionError.Forbidden
//   409  โ†’ UnionError.Conflict
//   422  โ†’ UnionError.Validation
//   5xx  โ†’ UnionError.SystemFailure

// โ”€โ”€ POST โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Rail<UserDto> created = await httpClient
    .PostAsJsonAsUnionAsync<CreateUserRequest, UserDto>(
        "/users", new CreateUserRequest("Alice"));

// โ”€โ”€ Chaining with the result โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
var summary = await httpClient
    .GetFromJsonAsUnionAsync<User>("/users/42")
    .BindAsync(u => httpClient.GetFromJsonAsUnionAsync<Order[]>($"/orders?userId={u.Id}"))
    .MapAsync(orders => new OrderSummary(orders.Length));

return summary.ToHttpResult();
// โ”€โ”€ Map: transform a successful value โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Rail<int> doubled = Union.Ok(5).Map(x => x * 2);       // Ok(10)
Rail<int> skipped = Union.Fail<int>(err).Map(x => x * 2); // still Fail

// โ”€โ”€ Bind: chain operations that may also fail โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Rail<OrderSummary> summary =
    await GetUserAsync(id)                             // Rail<User>
        .BindAsync(u => GetOrdersAsync(u.Id))          // Rail<Order[]>
        .MapAsync(orders => new OrderSummary(orders)); // Rail<OrderSummary>

// โ”€โ”€ Tap: side effects without altering the value โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
var result = await GetUserAsync(id)
    .TapAsync(u => logger.LogInformation("Accessed user {Id}", u.Id))
    .ToHttpResultAsync();   // side effect only on success path

// โ”€โ”€ Recover: provide a fallback for specific error types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Rail<User> withFallback = await GetUserAsync(id)
    .RecoverAsync<User, UnionError.NotFound>(_ => guestUser);

// More selective recovery
Rail<User> partial = await GetUserAsync(id)
    .RecoverAsync<User, UnionError.Unauthorized>(_ =>
        new User { Id = 0, Name = "Anonymous" });

// โ”€โ”€ Full chain example โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
return await GetUserAsync(id)
    .TapAsync(u => auditLog.LogAccessAsync(u.Id))
    .BindAsync(u => GetPermissionsAsync(u.Id))
    .MapAsync(perms => new UserProfile(perms))
    .RecoverAsync<UserProfile, UnionError.NotFound>(_ => UserProfile.Guest)
    .ToHttpResultAsync();
// โ”€โ”€ Wrap exception-based legacy code โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Rail<Report> result = await UnionWrapper.RunAsync(
    () => legacyReportService.GenerateAsync(reportId));
// Any exception โ†’ UnionError.SystemFailure(ex)

// โ”€โ”€ Wrap nullable-returning code โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Rail<User> maybeUser = await UnionWrapper.RunNullableAsync(
    () => legacyRepo.FindByEmailAsync(email));
// null โ†’ UnionError.NotFound("value")

// โ”€โ”€ From LanguageExt (Fin<T>) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Before:
Fin<User> fin = await repo.GetAsync(id);
return fin.Match(Succ: u => Results.Ok(u), Fail: e => Results.Problem(e.Message));

// After:
Rail<User> rail = await repo.GetAsync(id);  // change return type
return rail.ToHttpResult();

// โ”€โ”€ From ErrorOr โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Before:
ErrorOr<User> eo = await repo.GetAsync(id);
if (eo.IsError) return eo.Errors[0];

// After:
Rail<User> rail = await repo.GetAsync(id);
if (!rail.IsSuccess(out var user, out var err))
    return err.GetValueOrDefault();
Error Types

Semantic, closed union errors

Every possible error outcome is a named type โ€” no stringly-typed messages, no magic numbers.

NotFound
A resource with the given identifier does not exist.
HTTP 404
Conflict
The operation conflicts with the current resource state.
HTTP 409
Unauthorized
The caller is not authenticated.
HTTP 401
Forbidden
The caller is authenticated but lacks permission.
HTTP 403
Validation
One or more input fields failed validation.
HTTP 422
SystemFailure
An unexpected exception or infrastructure error.
HTTP 500
Custom
Domain-specific error with a machine-readable code and optional metadata.
Any status

Pattern matching on errors

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} field errors",
    UnionError.SystemFailure sf => sf.Ex.Message,
    UnionError.Custom c         => $"{c.Code}: {c.Message}",
    _                           => "Unknown error"
};

Custom domain errors

// Application-specific error with any HTTP status + metadata
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
    });

// Produces RFC 7807 ProblemDetails:
// {
//   "status": 429,
//   "title": "RATE_LIMIT_EXCEEDED",
//   "detail": "Too many requests...",
//   "retryAfter": 30,
//   "limit": 100
// }
Performance

Fast by design

Struct-based Rail<T> lives on the stack. Zero allocations on the success path. Measured with BenchmarkDotNet on .NET 8.

0.5
ns
Create success
0
B allocated
Success path
1.3
ns
Map operation
48
ns
MapAsync (ValueTask)
20
ns
3-op chain
Operation Mean Allocated Notes
Create success Rail<T>0.5 ns0 BStack-only, zero allocation
Create failure Rail<T>2.8 ns24 BError record allocation
Create Custom error~3.5 ns32 BRecord with optional extensions
Implicit conversion<1 ns0 BZero overhead
IsSuccess pattern0.6 ns0 BInline check
Map (success)1.3 ns0 BAggressiveInlining
Map (error)3.6 ns24 BShort-circuit
Chain 3ร— Map7.2 ns0 BZero-allocation chain
Bind (success)36 ns40 BFunction call overhead
MapAsync48 ns0 BValueTask overhead only
Service chain (3 ops)20 ns120 BReal-world scenario
Recover (matching)~2 ns0 BSingle is type check
Validation error103 ns464 BDictionary creation
Why so fast? Struct-based Rail<T> lives on the stack ยท Zero allocations on success ยท ValueTask async (vs Task) ยท AggressiveInlining on hot paths ยท configureProblem callback is null-guarded ยท Custom.Extensions is null by default

Run yourself: cd tests/UnionRailway.Benchmarks && dotnet run -c Release
vs Other Libraries

Why UnionRailway?

Compared with LanguageExt, ErrorOr, OneOf, and FluentResults โ€” the most popular C# result libraries.

Feature UnionRailway LanguageExt ErrorOr OneOf FluentResults
Native union alignment (.NET 11) โœ…โŒโŒโŒโŒ
Semantic, closed error model โœ… Closed unionString-basedCustom errorsAny typeString-based
ASP.NET Core (RFC 7807) โœ… Built-inโŒ ManualโŒ ManualโŒ ManualโŒ Manual
EF Core integration โœ… Built-inโŒโŒโŒโŒ
HttpClient integration โœ… Built-inโŒโŒโŒโŒ
OpenAPI / Swagger support โœ… Built-inโŒโŒโŒโŒ
Railway operators โœ… Map/Bind/Tap/Recoverโœ… Extensiveโœ… BasicโŒโœ… Basic
Async-first โœ… ValueTaskโœ… Taskโœ… TaskโŒโœ… Task
Pattern matching โœ… Nativeโœ… Customโœ… Customโœ… NativeโŒ
Zero-alloc success path โœ… StructโŒ ClassโŒ ClassโŒ ClassโŒ Class
Learning curve LowHigh (FP)LowMediumLow

The same endpoint โ€” four libraries

โŒ LanguageExt
Fin<User> result = await svc.GetAsync(id);

return result.Match(
    Succ: user => Results.Ok(user),
    Fail: error => error.Code switch
    {
        404 => Results.NotFound(),
        401 => Results.Unauthorized(),
        _   => Results.Problem(error.Message)
    }
);
// Must know HTTP codes. No RFC 7807.
โŒ ErrorOr
ErrorOr<User> result = await svc.GetAsync(id);

return result.Match(
    value  => Results.Ok(value),
    errors => errors[0].Type switch
    {
        ErrorType.NotFound    => Results.NotFound(),
        ErrorType.Unauthorized=> Results.Unauthorized(),
        _ => Results.Problem(errors[0].Description)
    }
);
// Manual mapping every time.
โŒ FluentResults
Result<User> result = await svc.GetAsync(id);

if (result.IsFailed)
{
    var error = result.Errors.First();
    return Results.Problem(error.Message);
    // โŒ No type safety, no semantic mapping
}

return Results.Ok(result.Value);
โœ… UnionRailway
Rail<User> result = await svc.GetAsync(id);

return result.ToHttpResult();
// โœ… RFC 7807 automatic
// โœ… Correct status codes always
// โœ… Type-safe error model
// โœ… One line โ€” everywhere
Packages

Modular ecosystem

Install only what you need. All packages follow semantic versioning and ship with XML documentation.

UnionRailway
Core Rail<T>, UnionError, Union helpers, railway operators (Map, Bind, Tap, Recover), and UnionWrapper.
UnionRailway.AspNetCore
RFC 7807 Problem Details mapping, RailEndpointFilter, UseRailwayExceptionHandler, AddRailway(), and IUnionErrorMapper.
UnionRailway.AspNetCore.OpenApi
Automatic Swagger/OpenAPI metadata for Rail<T> return types. Generates correct response schemas for each error variant.
UnionRailway.EntityFrameworkCore
EF Core query extensions: FirstOrDefaultAsUnionAsync and SaveChangesAsUnionAsync โ€” replace null checks and try-catch blocks.
UnionRailway.HttpClient
HttpClient extensions that automatically convert HTTP status codes to typed UnionError variants. No manual status-code branching.
Install all packages at once:
dotnet add package UnionRailway
dotnet add package UnionRailway.AspNetCore
dotnet add package UnionRailway.EntityFrameworkCore
dotnet add package UnionRailway.HttpClient
.NET 11 Ready

Future-proof from day one

UnionRailway is designed to require minimal migration effort when C# ships native union types.

Today โ€” .NET 8 polyfill

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

Tomorrow โ€” .NET 11 native (design goal)

// Illustrative โ€” syntax not yet finalised by the C# team
public union Rail<T>(T, UnionError);

// The library is designed so your call-sites need few changes ๐ŸŽ‰