UnionRailway.HttpClient 1.2.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package UnionRailway.HttpClient --version 1.2.0
                    
NuGet\Install-Package UnionRailway.HttpClient -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.HttpClient" Version="1.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="UnionRailway.HttpClient" Version="1.2.0" />
                    
Directory.Packages.props
<PackageReference Include="UnionRailway.HttpClient" />
                    
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.HttpClient --version 1.2.0
                    
#r "nuget: UnionRailway.HttpClient, 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.HttpClient@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.HttpClient&version=1.2.0
                    
Install as a Cake Addin
#tool nuget:?package=UnionRailway.HttpClient&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.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.2.1 92 5/5/2026
1.2.0 105 4/29/2026
1.1.0 103 4/25/2026
1.0.0 112 4/6/2026
0.1.0-ci.2 64 4/6/2026