Skip to content

Pragmatic.Result

Zero-allocation Result types for .NET 10 with Railway-Oriented Programming support.

Most .NET applications signal failure by throwing exceptions. This hides error paths from method signatures, forces callers to guess what to catch, and allocates expensive stack traces for expected business outcomes like “entity not found.” The compiler cannot enforce exhaustive handling — miss a catch block and you get a generic 500 instead of a meaningful error response.

// Without Pragmatic.Result: error paths are invisible, exceptions are expensive
public async Task<User> GetUserAsync(int id)
{
var user = await _repository.FindByIdAsync(id);
if (user is null)
throw new NotFoundException($"User {id} not found"); // Hidden, expensive
if (!user.IsActive)
throw new BusinessRuleException("User is inactive"); // Hidden, expensive
return user;
}

Pragmatic.Result makes error paths explicit in the type system. Return Result<TValue, TError> instead of throwing — the compiler ensures callers handle both success and failure. Zero allocation for the wrapper itself.

// With Pragmatic.Result: errors are visible, type-safe, zero-allocation
public async Task<Result<User, NotFoundError>> GetUserAsync(int id)
{
var user = await _repository.FindByIdAsync(id);
if (user is null)
return NotFoundError.Create("User", id); // Implicit conversion to failure
return user; // Implicit conversion to success
}
// Caller: exhaustive handling, compiler-enforced
var message = result.Match(
user => $"Found: {user.Name}",
error => $"Not found: {error.EntityId}");
PackageDescription
Pragmatic.ResultCore Result types and error interfaces
Pragmatic.Result.AspNetCoreProblemDetails, Minimal API & Controller support
Pragmatic.Result.EntityFrameworkCoreQuery extensions returning Result types
Terminal window
# Core package
dotnet add package Pragmatic.Result
# ASP.NET Core integration (ProblemDetails, Minimal API, Controllers)
dotnet add package Pragmatic.Result.AspNetCore
# EF Core integration (Query extensions)
dotnet add package Pragmatic.Result.EntityFrameworkCore
  • Zero-allocation structs - All types are readonly struct for maximum performance
  • Type-safe error handling - Errors must implement IError interface
  • Railway-Oriented Programming - Map, Bind, Match for functional composition
  • Multiple error types - Generated variants support up to 8 error types via source generators
  • HTTP error types - Built-in NotFoundError, UnauthorizedError, ForbiddenError, ConflictError
  • ProblemDetails - Automatic RFC 7807 responses for HTTP APIs
  • Controller & Minimal API - Full support for both ASP.NET Core patterns
  • Localization ready - IErrorMessageResolver for custom message resolution
  • EF Core extensions - Query methods that return Result types
using Pragmatic.Result;
using Pragmatic.Result.Http;
// Create success/failure
Result<User, NotFoundError> result = user;
Result<User, NotFoundError> error = NotFoundError.Create("User", userId);
// Pattern matching
var output = result.Match(
user => $"Found: {user.Name}",
error => $"Error: {error.EntityType} not found");
// Safe access
if (result.TryGetValue(out var value))
Console.WriteLine(value.Name);
Section titled “ASP.NET Core - Automatic Result Handling (Recommended)”

The recommended approach uses automatic Result-to-HTTP response conversion via filters. Return Result types directly from your endpoints - no manual conversion needed.

using Pragmatic.Result.AspNetCore;
// Register services
builder.Services.AddPragmaticResult();
// Add global ResultActionFilter for Controllers
builder.Services.AddControllers(options =>
{
options.Filters.Add<ResultActionFilter>();
});
var app = builder.Build();
// Enable automatic Result handling for Minimal APIs
var api = app.MapGroup("").WithResultHandling();
// Endpoints return Result types directly - automatic HTTP conversion
api.MapGet("/users/{id}", async (int id, UserService svc)
=> await svc.GetByIdAsync(id));
// Returns Result<User, NotFoundError>
// Success: 200 OK with JSON body
// Failure: 404 ProblemDetails
api.MapPost("/users", async (CreateUserRequest req, UserService svc)
=> await svc.CreateAsync(req));
// Returns Result<User, ConflictError>
// Success: 200 OK with JSON body
// Failure: 409 ProblemDetails
api.MapDelete("/users/{id}", async (int id, UserService svc)
=> await svc.DeleteAsync(id));
// Returns VoidResult<NotFoundError>
// Success: 204 No Content
// Failure: 404 ProblemDetails
// Opt-out of automatic handling for specific endpoints
api.MapGet("/raw/{id}", [SkipResultHandling] async (int id, UserService svc)
=> await svc.GetByIdAsync(id));
// Returns raw Result object as JSON
using Pragmatic.Result.AspNetCore;
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly UserService _userService;
public UsersController(UserService userService)
=> _userService = userService;
// Return Result directly - filter handles HTTP conversion
[HttpGet("{id}")]
[ProducesResponseType<User>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<Result<User, NotFoundError>> Get(int id)
=> await _userService.GetByIdAsync(id);
[HttpPost]
[ProducesResponseType<User>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<Result<User, ConflictError>> Create([FromBody] CreateUserRequest request)
=> await _userService.CreateAsync(request);
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<VoidResult<NotFoundError>> Delete(int id)
=> await _userService.DeleteAsync(id);
// Opt-out for specific action
[HttpGet("raw/{id}")]
[SkipResultHandling]
public async Task<Result<User, NotFoundError>> GetRaw(int id)
=> await _userService.GetByIdAsync(id);
}

For scenarios requiring custom status codes or location headers, use manual extension methods:

// Minimal API - manual control
app.MapPost("/users", async (CreateUserRequest req, IProblemDetailsFactory factory, UserService svc) =>
{
var result = await svc.CreateAsync(req);
return result.ToCreatedResult(factory, $"/users/{result.Value?.Id}");
// Success: 201 Created with Location header
});
// Controller - manual control
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
{
var result = await _userService.CreateAsync(request);
return result.ToCreatedAtActionResult(_factory, nameof(Get), new { id = result.Value?.Id });
}
// Available extension methods:
// ToHttpResult(factory, successStatusCode) - Custom status code
// ToCreatedResult(factory, location) - 201 with Location header
// ToNoContentResult(factory) - 204 No Content
// ToActionResult(factory, successStatusCode) - For Controllers
// ToCreatedAtActionResult(factory, actionName, routeValues)
using Pragmatic.Result.EntityFrameworkCore;
// Find by primary key
var result = await dbContext.Users.FindAsResultAsync<User, int>(id);
// Returns Result<User, NotFoundError>
// Query with predicate
var result = await dbContext.Users
.Where(u => u.Email == email)
.FirstOrDefaultAsResultAsync();
// Single with predicate
var result = await dbContext.Orders
.SingleOrDefaultAsResultAsync(o => o.OrderNumber == orderNum);
Result<string, ValidationError> Process(string input)
{
return Result<string, ValidationError>.Success(input)
.Map(s => s.Trim())
.Bind(s => s.Length > 0
? Result<string, ValidationError>.Success(s)
: new ValidationError("Empty input"))
.Map(s => s.ToUpperInvariant());
}
Result<User, ValidationError, NotFoundError> GetUser(string id)
{
if (string.IsNullOrEmpty(id))
return new ValidationError("ID required");
if (!UserExists(id))
return NotFoundError.Create("User", id);
return LoadUser(id);
}
// Match handles all cases
var output = result.Match(
user => user.Name,
validation => validation.Message,
notFound => $"{notFound.EntityType} not found");

When your domain operations share the same set of error types across multiple methods, you can define a custom Result type using [GenerateResult]. This generates a zero-allocation struct with implicit conversions, exhaustive Match(), and type-safe error access.

using Pragmatic.Result.Attributes;
using Pragmatic.Result.Http;
// 1. Define an interface with the error types for this domain
[GenerateResult(typeof(NotFoundError), typeof(ValidationError), typeof(ConflictError))]
public interface IUserOperationResult { }
// 2. The source generator creates: UserOperationResult<TValue>
// 3. Use the generated type in your service
public class UserService
{
public UserOperationResult<User> CreateUser(CreateUserRequest request)
{
if (!IsValid(request))
return new ValidationError("Invalid email"); // Implicit conversion
if (Exists(request.Email))
return ConflictError.AlreadyExists("User", request.Email);
return createdUser; // Success
}
public UserOperationResult<User> GetUser(string id)
{
if (string.IsNullOrEmpty(id))
return new ValidationError("ID required");
var user = FindById(id);
if (user is null)
return NotFoundError.Create("User", id);
return user;
}
}
// 4. Exhaustive Match handles all error types
var result = userService.CreateUser(request);
var response = result.Match(
user => $"Created: {user.Name}",
notFound => $"Not found: {notFound.EntityType}",
validation => $"Invalid: {validation.Message}",
conflict => $"Already exists: {conflict.EntityType}");

The [GenerateResult] attribute supports:

  • Up to 8 error types per Result variant
  • Custom type naming via TypeName property (default: derived from interface name)
  • All features of the standard multi-error Result types (Match, Map, Bind, TryGet)

Note: [GenerateResult] generates a named Result type from your interface specification. For ad-hoc multi-error results, use the built-in Result<T, E1, E2> generic variants directly.

VoidResult<ValidationError> ValidateAge(int age)
{
if (age < 0)
return new ValidationError("Age cannot be negative");
return VoidResult<ValidationError>.Success();
}
// In Minimal API
app.MapPut("/users/{id}/validate", async (int id, IProblemDetailsFactory factory, UserService svc) =>
{
var result = await svc.ValidateUserAsync(id);
return result.ToHttpResult(factory); // 204 or ProblemDetails
});

Convert nullable values and exception-throwing code into Result types:

using Pragmatic.Result.Extensions;
// FromNullable — reference types
User? user = await repository.FindByIdAsync(id);
var result = ResultExtensions.FromNullable(user, NotFoundError.Create("User", id));
// Non-null → Success(user), null → Failure(NotFoundError)
// FromNullable — value types
int? count = cache.Get<int>(key);
var result = ResultExtensions.FromNullable(count, new BadRequestError { Reason = "Missing" });
// HasValue → Success(42), null → Failure(error)
// FromNullable — lazy error (factory only called on null)
var result = ResultExtensions.FromNullable(user, () => NotFoundError.For<User>(id));
// TryCatch — wrap exception-throwing code
var result = ResultExtensions.TryCatch(
() => JsonSerializer.Deserialize<Order>(json)!,
ex => InternalServerError.From(ex));
// TryCatchAsync — async variant
var result = await ResultExtensions.TryCatchAsync(
async ct => await httpClient.GetFromJsonAsync<Order>(url, ct),
ex => DependencyError.Unavailable("OrderService"),
cancellationToken);

Split and filter collections of Results:

using Pragmatic.Result.Extensions;
Result<User, NotFoundError>[] results = await Task.WhenAll(
ids.Select(id => userService.GetByIdAsync(id)));
// Extract only successes
IEnumerable<User> users = results.GetSuccesses();
// Extract only errors
IEnumerable<NotFoundError> errors = results.GetFailures();
// Split into both at once
var (users, errors) = results.Partition();
Console.WriteLine($"Found {users.Count}, missing {errors.Count}");
// Collect all or aggregate errors (fail if ANY failed)
var allOrNothing = ResultExtensions.CollectAll(results);
// All success → Success(User[]), any failure → Failure(AggregateError)

Chain async operations in a railway-oriented style:

var result = await userService.GetByIdAsync(userId) // Task<Result<User, Error>>
.MapAsync(user => enrichmentService.EnrichAsync(user)) // Transform value
.BindAsync(user => authService.CheckAccessAsync(user)) // Chain Result-returning op
.TapAsync(user => auditService.LogAccessAsync(user)) // Side effect on success
.OnSuccessAsync(user => metricsService.RecordAsync(user)) // Alias for TapAsync
.OnFailureAsync(error => alertService.NotifyAsync(error)); // Side effect on failure
// EnsureAsync — validate with async predicate
var result = await orderService.GetOrderAsync(orderId)
.EnsureAsync(
async order => await inventoryService.IsAvailableAsync(order),
order => BusinessRuleError.Create("OutOfStock"));
// OrElseAsync — recover from failure
var result = await primaryCache.GetAsync<User>(key)
.OrElseAsync(async _ => await fallbackCache.GetAsync<User>(key));
// MatchAsync — extract final value
string message = await userService.GetByIdAsync(id)
.MatchAsync(
user => $"Found: {user.Name}",
error => $"Error: {error.Code}");
Maybe<string> FindSetting(string key)
{
return settings.TryGetValue(key, out var value)
? Maybe<string>.Some(value)
: Maybe<string>.None();
}
// Safe access
var theme = FindSetting("theme").GetValueOrDefault("light");
TypeInherit FromWhen to Use
IErrorMinimal interface: Code, StatusCode, optional Title
ErrorIErrorBase record with MessageKey, Parameters, IsTransient, RetryAfter
Built-in HTTP errorsErrorStandard HTTP failures (see table below)
Custom error recordIErrorLightweight domain errors (validation, business rules)
Custom error recordErrorRich domain errors needing localization keys and parameters

When to implement IError directly — Simple errors with just a code and status:

public record InsufficientFundsError(decimal Requested, decimal Available) : IError
{
public string Code => "INSUFFICIENT_FUNDS";
public int StatusCode => 422;
}

When to inherit from Error — Errors needing localization, retry logic, or parameters:

public record RateLimitError : Error
{
public override string Code => "RATE_LIMITED";
public override int StatusCode => 429;
public override bool IsTransient => true;
public override TimeSpan? RetryAfter { get; init; } = TimeSpan.FromSeconds(60);
}
ErrorStatusUsage
BadRequestError400Malformed request, missing headers
UnauthorizedError401Authentication required
ForbiddenError403Permission denied
NotFoundError404Resource not found
ConflictError409Resource conflict
BusinessRuleError422Business rule violation
InternalServerError500Unhandled exception
DependencyError502/503/504External service failure
// Creation examples
BadRequestError.MalformedJson();
BadRequestError.MissingHeader("X-Api-Key");
UnauthorizedError.InvalidCredentials();
ForbiddenError.Create("admin/settings", "write");
NotFoundError.Create("User", userId);
ConflictError.AlreadyExists("User", email);
BusinessRuleError.InsufficientFunds(requested: 100m, available: 50m);
BusinessRuleError.LimitExceeded("order_items", limit: 10, requested: 15);
InternalServerError.From(exception, includeDetails: env.IsDevelopment());
DependencyError.Unavailable("PaymentService", retryAfter: TimeSpan.FromSeconds(30));
DependencyError.Timeout("InventoryService");

Errors are automatically converted to RFC 7807 ProblemDetails:

{
"type": "https://httpstatuses.io/404",
"title": "Not Found",
"status": 404,
"detail": "User not found",
"code": "NOT_FOUND",
"entityType": "User",
"entityId": "123"
}

Error codes are designed for localization at the serialization boundary. Implement IErrorMessageResolver to provide localized messages in ProblemDetails:

// Implement custom message resolver
public class LocalizedErrorResolver : IErrorMessageResolver
{
private readonly IStringLocalizer _localizer;
public LocalizedErrorResolver(IStringLocalizer localizer)
=> _localizer = localizer;
public string? Resolve(string code, object? context)
=> _localizer[code]?.Value;
}
// Register with custom resolver
builder.Services.AddPragmaticResult<LocalizedErrorResolver>();

The resolver is called when converting errors to ProblemDetails, populating the detail field with localized messages.

TypeUse CaseExample
Result<T, E>Operation returns a value or one error typeResult<User, NotFoundError>
Result<T, E1, E2, ...>Operation can fail with 2-6 different error typesResult<Order, NotFoundError, ConflictError>
VoidResult<E>Operation can fail but returns nothing on successVoidResult<ValidationError>
Maybe<T>Absence is normal, not an errorMaybe<CachedValue>

Use single error (Result<T, TError>) when:

  • Only one logical failure mode exists (e.g., not found, validation failed)
  • All failures map to the same HTTP status code
  • Error carries all needed context in one type

Use multi-error (Result<T, TError1, TError2>) when:

  • Different failures have different semantics (404 vs 409 vs 422)
  • Caller needs to handle each error type differently
  • Match() should force exhaustive handling of all cases

Use AggregateError when:

  • Running parallel operations that can each fail
  • Need to collect ALL errors instead of failing fast
  • results.CollectAll()Result<T[], AggregateError>
ScenarioChooseSignature
Get user by IDSingle errorResult<User, NotFoundError>
Validate input (multiple fields)Single error (aggregate)Result<T, ValidationError>
Get user + check permissionMulti-error (2)Result<User, NotFoundError, ForbiddenError>
Update 3 entities in parallelAggregateErrorresults.CollectAll()
Cache lookup (miss is normal)MaybeMaybe<CachedValue>
  • Maybe — Absence is NOT exceptional (cache miss, optional config, TryParse)
  • Result — Absence is SEMANTIC (entity should exist, 404 is meaningful)

Result types include built-in System.Text.Json converters via ResultJsonConverterFactory.

// ASP.NET Core
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new ResultJsonConverterFactory());
});
// Standalone
var options = new JsonSerializerOptions();
options.Converters.Add(new ResultJsonConverterFactory());
// Result<T, E> — Success
{ "isSuccess": true, "value": { "id": 1, "name": "Alice" } }
// Result<T, E> — Failure
{ "isSuccess": false, "error": { "code": "NOT_FOUND", "statusCode": 404 } }
// VoidResult<E> — Success
{ "isSuccess": true }
// VoidResult<E> — Failure
{ "isSuccess": false, "error": { "code": "VALIDATION_ERROR" } }
// Maybe<T> — Some
{ "hasValue": true, "value": "cached-data" }
// Maybe<T> — None
null

All Result types are designed for zero-allocation operation on the happy path.

DecisionImpact
readonly struct for all typesNo heap allocation for the Result wrapper itself
[MethodImpl(AggressiveInlining)] on hot-path methodsJIT inlines IsSuccess, Value, Match, TryGetValue, factory methods
Private constructors + static factory methodsCompiler can optimize construction, prevents invalid states
byte index for multi-error variantsSingle byte discriminator instead of per-error bool fields
[DoesNotReturn] on throw helpersHelps JIT eliminate dead code paths after guards
[MaybeNullWhen(false)] on TryGet out paramsEnables nullable flow analysis without runtime cost
  • The Result wrapper itself: Never allocates (it is a struct on the stack or inline in the parent object).
  • The payload (TValue, TError): Allocates only if the type is a reference type (class, record class). If you use readonly struct or record struct for your error types, the entire operation is allocation-free.
  • Match with lambdas: May allocate a delegate if the lambda captures local state. For allocation-free Match, use the TryGetValue/TryGetError pattern or a static lambda.

The Pragmatic.Result.Benchmarks project measures real performance. To run:

Terminal window
dotnet run -c Release --project Pragmatic.Result/benchmarks/Pragmatic.Result.Benchmarks/

Typical results on modern hardware show sub-nanosecond overhead for creation, state checks, and Match operations, with 0 bytes allocated for the wrapper.

See samples/Pragmatic.Result.Samples/ for 10 runnable scenarios: basic Result, railway-oriented (Map/Bind), multi-error, VoidResult, Maybe, deconstruction, TryCatch, async pipeline, side effects (Tap/OnSuccess/OnFailure), and recovery patterns (Ensure chain, implicit conversions).

Sub-package samples: samples/Pragmatic.Result.AspNetCore.Samples/ (HTTP integration), samples/Pragmatic.Result.EFCore.Samples/ (database integration).

Pragmatic.Result.EFCore wraps DbContext.SaveChangesAsync() to return typed errors instead of throwing DbUpdateException:

Terminal window
dotnet add package Pragmatic.Result.EFCore
dotnet add package Pragmatic.Result.EFCore.PostgreSQL # or .SqlServer, .Sqlite, .MySql
// Instead of try/catch with DbUpdateException:
var result = await context.SaveChangesAsResultAsync();
result.Match(
onSuccess: () => Console.WriteLine("Saved!"),
onDbConflict: conflict => Console.WriteLine($"Conflict: {conflict.Reason}"),
onDbConstraint: constraint => Console.WriteLine($"Constraint: {constraint.Details}"));
Error TypeMaps FromHTTP Status
DbConflictErrorUnique constraint / concurrency violation409
DbConstraintErrorForeign key / check constraint violation400
DbNullConstraintErrorNOT NULL violation400
DbMaxLengthErrorMax length exceeded400
DbNumericOverflowErrorNumeric overflow400
DbTransientErrorTimeout / deadlock / connection failure503

Each database provider package includes an IDbExceptionParser that extracts constraint names, table names, and column names from provider-specific exceptions:

// Register provider-specific parser for better error details
services.AddPragmaticResultEFCore() // Core
.AddPostgreSqlExceptionParser(); // PostgreSQL-specific error parsing
// or .AddSqlServerExceptionParser()
// or .AddSqliteExceptionParser()
// or .AddMySqlExceptionParser()
// Find by ID — returns Result<T, NotFoundError> instead of null
var user = await context.Users.FindAsResultAsync<User, int>(42);
// FirstOrDefault — returns Result<T, NotFoundError>
var order = await context.Orders
.Where(o => o.CustomerId == customerId)
.FirstOrDefaultAsResultAsync();
[Fact]
public void Success_ReturnsValue()
{
var result = Result<int, Error>.Success(42);
Assert.True(result.IsSuccess);
Assert.Equal(42, result.Value);
}
[Fact]
public void Match_OnFailure_CallsErrorHandler()
{
var result = Result<int, StringError>.Failure(new StringError("not found"));
var output = result.Match(
onSuccess: v => $"got {v}",
onFailure: e => $"error: {e.Message}");
Assert.Equal("error: not found", output);
}
[Fact]
public async Task BindAsync_ChainsOperations()
{
var result = Result<int, IError>.Success(5);
var final = await result
.MapAsync(x => Task.FromResult(x * 2))
.BindAsync(x => Task.FromResult(
x > 5
? Result<string, IError>.Success($"ok:{x}")
: Result<string, IError>.Failure(new StringError("too small"))));
Assert.True(final.IsSuccess);
Assert.Equal("ok:10", final.Value);
}

The Pragmatic.Result.Analyzers package includes a Roslyn analyzer for safe Result usage:

IDSeverityDescription
PRAG0001WarningUnsafe Result.Value access without IsSuccess/IsFailure check
// Guard with if
if (result.IsSuccess)
Console.WriteLine(result.Value);
// Guard with ternary
var value = result.IsSuccess ? result.Value : defaultValue;
// Guard return pattern
if (!result.IsSuccess) return;
var value = result.Value;
// Use Match (no .Value needed)
var message = result.Match(
value => $"Got: {value}",
error => $"Error: {error}");

Pragmatic.Result.AspNetCore ships two built-in OpenAPI enrichers for richer API documentation.

builder.Services.AddOpenApi(options =>
{
options.AddResultTypeSupport(); // Registers ErrorSchemaEnricher + CommonSchemaEnricher
});

Generates ProblemDetails-compatible OpenAPI schemas for all IError types used in endpoints. Error-specific extension properties (entityType, entityId, field, violations, etc.) appear as documented schema fields:

// Generated schema for NotFoundError
{
"type": "object",
"properties": {
"type": { "type": "string" },
"title": { "type": "string" },
"status": { "type": "integer" },
"code": { "type": "string", "example": "NOT_FOUND" },
"entityType": { "type": "string" },
"entityId": { "type": "string" }
}
}

Adds OpenAPI format annotations to common .NET types:

.NET TypeOpenAPI Format
Guiduuid
DateTime / DateTimeOffsetdate-time
DateOnlydate
Enum (with JsonStringEnumConverter)String enum with all values listed

When JsonStringEnumConverter is active (the default via PragmaticApp.RunAsync()), enum properties are documented as string values — not integers:

{
"priority": {
"type": "string",
"enum": ["Standard", "Express", "Overnight"]
}
}
  • .NET 10.0 or later

MIT