UnionRailway 1.2.0
See the version list below for details.
dotnet add package UnionRailway --version 1.2.0
NuGet\Install-Package UnionRailway -Version 1.2.0
<PackageReference Include="UnionRailway" Version="1.2.0" />
<PackageVersion Include="UnionRailway" Version="1.2.0" />
<PackageReference Include="UnionRailway" />
paket add UnionRailway --version 1.2.0
#r "nuget: UnionRailway, 1.2.0"
#:package UnionRailway@1.2.0
#addin nuget:?package=UnionRailway&version=1.2.0
#tool nuget:?package=UnionRailway&version=1.2.0
UnionRailway
<div align="center">
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
- โ
ValueTaskfor async (vsTask) - โ AggressiveInlining on hot paths
- โ
configureProblemcallback is null-guarded โ zero overhead when unused - โ
Custom.Extensionsisnullby 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
Comparison with other libraries โ
Why choose UnionRailway over LanguageExt, ErrorOr, OneOf, FluentResultsContributing โ
How to contribute, coding conventions, PR processChangelog โ
Version history and breaking changes
๐ฆ Packages
โค๏ธ 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 | Versions 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. |
-
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.