Type-safe error handling for C# that actually feels native.
Zero boilerplate. RFC 7807 Problem Details. Built-in ecosystem integration.
Exceptions are slow, invisible to callers, and create inconsistent API responses. There's a better way.
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");
}
}
// 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!
UnionRailway is focused. ~15 core methods, deep ecosystem integration, and a clean learning curve.
Rail<T>Lives on the stack. Zero allocations on the happy path. The success value is always 0.5 ns away.
Seven semantic error variants (NotFound, Conflict, Unauthorized, Forbidden, Validation, SystemFailure, Custom) โ no string soup.
One call to .ToHttpResult() produces standards-compliant Problem Details responses. No manual mapping.
Map, Bind, Tap, Recover โ chain operations while short-circuiting on the first error.
Endpoint filter, global exception handler, AddRailway() DI setup, and OpenAPI metadata โ all built-in.
FirstOrDefaultAsUnionAsync and SaveChangesAsUnionAsync replace boilerplate null checks and try-catch.
GetFromJsonAsUnionAsync automatically maps 4xx/5xx status codes to typed UnionError variants.
UnionWrapper.RunAsync and RunNullableAsync wrap legacy exception-based or nullable code without refactoring.
The struct polyfill is designed as a drop-in for native C# union types โ aiming for minimal breaking changes when they ship.
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();
Every possible error outcome is a named type โ no stringly-typed messages, no magic numbers.
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"
};
// 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
// }
Struct-based Rail<T> lives on the stack. Zero allocations on the success path. Measured with BenchmarkDotNet on .NET 8.
| Operation | Mean | Allocated | Notes |
|---|---|---|---|
Create success Rail<T> | 0.5 ns | 0 B | Stack-only, zero allocation |
Create failure Rail<T> | 2.8 ns | 24 B | Error record allocation |
| Create Custom error | ~3.5 ns | 32 B | Record with optional extensions |
| Implicit conversion | <1 ns | 0 B | Zero overhead |
| IsSuccess pattern | 0.6 ns | 0 B | Inline check |
| Map (success) | 1.3 ns | 0 B | AggressiveInlining |
| Map (error) | 3.6 ns | 24 B | Short-circuit |
| Chain 3ร Map | 7.2 ns | 0 B | Zero-allocation chain |
| Bind (success) | 36 ns | 40 B | Function call overhead |
| MapAsync | 48 ns | 0 B | ValueTask overhead only |
| Service chain (3 ops) | 20 ns | 120 B | Real-world scenario |
| Recover (matching) | ~2 ns | 0 B | Single is type check |
| Validation error | 103 ns | 464 B | Dictionary creation |
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
cd tests/UnionRailway.Benchmarks && dotnet run -c Release
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 union | String-based | Custom errors | Any type | String-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 | Low | High (FP) | Low | Medium | Low |
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<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.
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);
Rail<User> result = await svc.GetAsync(id);
return result.ToHttpResult();
// โ
RFC 7807 automatic
// โ
Correct status codes always
// โ
Type-safe error model
// โ
One line โ everywhere
Install only what you need. All packages follow semantic versioning and ship with XML documentation.
Rail<T>, UnionError, Union helpers, railway operators (Map, Bind, Tap, Recover), and UnionWrapper.RailEndpointFilter, UseRailwayExceptionHandler, AddRailway(), and IUnionErrorMapper.Rail<T> return types. Generates correct response schemas for each error variant.FirstOrDefaultAsUnionAsync and SaveChangesAsUnionAsync โ replace null checks and try-catch blocks.dotnet add package UnionRailwaydotnet add package UnionRailway.AspNetCoredotnet add package UnionRailway.EntityFrameworkCoredotnet add package UnionRailway.HttpClient
UnionRailway is designed to require minimal migration effort when C# ships native union types.
[Union] // struct-based polyfill
public readonly struct Rail<T>
{
// ...
}
// 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 ๐