UnionRailway.AspNetCore
1.2.1
dotnet add package UnionRailway.AspNetCore --version 1.2.1
NuGet\Install-Package UnionRailway.AspNetCore -Version 1.2.1
<PackageReference Include="UnionRailway.AspNetCore" Version="1.2.1" />
<PackageVersion Include="UnionRailway.AspNetCore" Version="1.2.1" />
<PackageReference Include="UnionRailway.AspNetCore" />
paket add UnionRailway.AspNetCore --version 1.2.1
#r "nuget: UnionRailway.AspNetCore, 1.2.1"
#:package UnionRailway.AspNetCore@1.2.1
#addin nuget:?package=UnionRailway.AspNetCore&version=1.2.1
#tool nuget:?package=UnionRailway.AspNetCore&version=1.2.1
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();
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
objectwill occur
When to use what:
- Use
Mapwhen transforming the success value (no error path) - Use
Bindwhen chaining operations that returnRail<T>(with explicit types) - Use
BindObjectwhen returningobjector 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
- โ
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();
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 ofTask<Rail<T>>for hot paths - โ
Prefer
MapAsync/BindAsyncover synchronous versions when awaiting - โ
Use
Rail<Unit>for void-like operations (0 allocation) - โ
Avoid boxing to
objectunless necessary (DTOs at HTTP layer) - โ
Use
AggressiveInliningextension methods for custom operators
๐ 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. net11.0 is compatible. |
-
net11.0
- UnionRailway (>= 1.2.1)
-
net8.0
- UnionRailway (>= 1.2.1)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on UnionRailway.AspNetCore:
| Package | Downloads |
|---|---|
|
UnionRailway.AspNetCore.OpenApi
OpenAPI and Minimal API metadata conventions for UnionRailway Rail<T> endpoints. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.2.1 | 107 | 5/5/2026 |
| 1.2.0 | 121 | 4/29/2026 |
| 1.1.0 | 113 | 4/25/2026 |
| 1.0.0 | 108 | 4/6/2026 |
| 0.1.0-ci.2 | 58 | 4/6/2026 |