Pragmatic.Result
Zero-allocation Result types for .NET 10 with Railway-Oriented Programming support.
The Problem
Section titled “The Problem”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 expensivepublic 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;}The Solution
Section titled “The Solution”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-allocationpublic 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-enforcedvar message = result.Match( user => $"Found: {user.Name}", error => $"Not found: {error.EntityId}");Packages
Section titled “Packages”| Package | Description |
|---|---|
Pragmatic.Result | Core Result types and error interfaces |
Pragmatic.Result.AspNetCore | ProblemDetails, Minimal API & Controller support |
Pragmatic.Result.EntityFrameworkCore | Query extensions returning Result types |
Installation
Section titled “Installation”# Core packagedotnet 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.EntityFrameworkCoreFeatures
Section titled “Features”- Zero-allocation structs - All types are
readonly structfor maximum performance - Type-safe error handling - Errors must implement
IErrorinterface - Railway-Oriented Programming -
Map,Bind,Matchfor 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 -
IErrorMessageResolverfor custom message resolution - EF Core extensions - Query methods that return Result types
Quick Start
Section titled “Quick Start”Basic Result
Section titled “Basic Result”using Pragmatic.Result;using Pragmatic.Result.Http;
// Create success/failureResult<User, NotFoundError> result = user;Result<User, NotFoundError> error = NotFoundError.Create("User", userId);
// Pattern matchingvar output = result.Match( user => $"Found: {user.Name}", error => $"Error: {error.EntityType} not found");
// Safe accessif (result.TryGetValue(out var value)) Console.WriteLine(value.Name);ASP.NET Core - Automatic Result Handling (Recommended)
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 servicesbuilder.Services.AddPragmaticResult();
// Add global ResultActionFilter for Controllersbuilder.Services.AddControllers(options =>{ options.Filters.Add<ResultActionFilter>();});
var app = builder.Build();
// Enable automatic Result handling for Minimal APIsvar api = app.MapGroup("").WithResultHandling();
// Endpoints return Result types directly - automatic HTTP conversionapi.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 endpointsapi.MapGet("/raw/{id}", [SkipResultHandling] async (int id, UserService svc) => await svc.GetByIdAsync(id)); // Returns raw Result object as JSONASP.NET Core - Controllers (Automatic)
Section titled “ASP.NET Core - Controllers (Automatic)”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);}Manual Conversion (Advanced)
Section titled “Manual Conversion (Advanced)”For scenarios requiring custom status codes or location headers, use manual extension methods:
// Minimal API - manual controlapp.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)Entity Framework Core Integration
Section titled “Entity Framework Core Integration”using Pragmatic.Result.EntityFrameworkCore;
// Find by primary keyvar result = await dbContext.Users.FindAsResultAsync<User, int>(id);// Returns Result<User, NotFoundError>
// Query with predicatevar result = await dbContext.Users .Where(u => u.Email == email) .FirstOrDefaultAsResultAsync();
// Single with predicatevar result = await dbContext.Orders .SingleOrDefaultAsResultAsync(o => o.OrderNumber == orderNum);Railway-Oriented Programming
Section titled “Railway-Oriented Programming”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());}Multiple Error Types
Section titled “Multiple Error Types”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 casesvar output = result.Match( user => user.Name, validation => validation.Message, notFound => $"{notFound.EntityType} not found");Custom Result Types with [GenerateResult]
Section titled “Custom Result Types with [GenerateResult]”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 servicepublic 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 typesvar 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
TypeNameproperty (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-inResult<T, E1, E2>generic variants directly.
VoidResult for Void Operations
Section titled “VoidResult for Void Operations”VoidResult<ValidationError> ValidateAge(int age){ if (age < 0) return new ValidationError("Age cannot be negative"); return VoidResult<ValidationError>.Success();}
// In Minimal APIapp.MapPut("/users/{id}/validate", async (int id, IProblemDetailsFactory factory, UserService svc) =>{ var result = await svc.ValidateUserAsync(id); return result.ToHttpResult(factory); // 204 or ProblemDetails});Factory Methods
Section titled “Factory Methods”Convert nullable values and exception-throwing code into Result types:
using Pragmatic.Result.Extensions;
// FromNullable — reference typesUser? user = await repository.FindByIdAsync(id);var result = ResultExtensions.FromNullable(user, NotFoundError.Create("User", id));// Non-null → Success(user), null → Failure(NotFoundError)
// FromNullable — value typesint? 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 codevar result = ResultExtensions.TryCatch( () => JsonSerializer.Deserialize<Order>(json)!, ex => InternalServerError.From(ex));
// TryCatchAsync — async variantvar result = await ResultExtensions.TryCatchAsync( async ct => await httpClient.GetFromJsonAsync<Order>(url, ct), ex => DependencyError.Unavailable("OrderService"), cancellationToken);Collection Extensions
Section titled “Collection Extensions”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 successesIEnumerable<User> users = results.GetSuccesses();
// Extract only errorsIEnumerable<NotFoundError> errors = results.GetFailures();
// Split into both at oncevar (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)Async Pipeline
Section titled “Async Pipeline”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 predicatevar result = await orderService.GetOrderAsync(orderId) .EnsureAsync( async order => await inventoryService.IsAvailableAsync(order), order => BusinessRuleError.Create("OutOfStock"));
// OrElseAsync — recover from failurevar result = await primaryCache.GetAsync<User>(key) .OrElseAsync(async _ => await fallbackCache.GetAsync<User>(key));
// MatchAsync — extract final valuestring message = await userService.GetByIdAsync(id) .MatchAsync( user => $"Found: {user.Name}", error => $"Error: {error.Code}");Maybe for Optional Values
Section titled “Maybe for Optional Values”Maybe<string> FindSetting(string key){ return settings.TryGetValue(key, out var value) ? Maybe<string>.Some(value) : Maybe<string>.None();}
// Safe accessvar theme = FindSetting("theme").GetValueOrDefault("light");Error Types
Section titled “Error Types”Error Hierarchy
Section titled “Error Hierarchy”| Type | Inherit From | When to Use |
|---|---|---|
IError | — | Minimal interface: Code, StatusCode, optional Title |
Error | IError | Base record with MessageKey, Parameters, IsTransient, RetryAfter |
| Built-in HTTP errors | Error | Standard HTTP failures (see table below) |
| Custom error record | IError | Lightweight domain errors (validation, business rules) |
| Custom error record | Error | Rich 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);}Built-in HTTP Errors
Section titled “Built-in HTTP Errors”| Error | Status | Usage |
|---|---|---|
BadRequestError | 400 | Malformed request, missing headers |
UnauthorizedError | 401 | Authentication required |
ForbiddenError | 403 | Permission denied |
NotFoundError | 404 | Resource not found |
ConflictError | 409 | Resource conflict |
BusinessRuleError | 422 | Business rule violation |
InternalServerError | 500 | Unhandled exception |
DependencyError | 502/503/504 | External service failure |
// Creation examplesBadRequestError.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");ProblemDetails Format
Section titled “ProblemDetails Format”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"}Localization
Section titled “Localization”Error codes are designed for localization at the serialization boundary.
Implement IErrorMessageResolver to provide localized messages in ProblemDetails:
// Implement custom message resolverpublic 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 resolverbuilder.Services.AddPragmaticResult<LocalizedErrorResolver>();The resolver is called when converting errors to ProblemDetails, populating the detail field with localized messages.
When to Use What
Section titled “When to Use What”Choosing a Result Type
Section titled “Choosing a Result Type”| Type | Use Case | Example |
|---|---|---|
Result<T, E> | Operation returns a value or one error type | Result<User, NotFoundError> |
Result<T, E1, E2, ...> | Operation can fail with 2-6 different error types | Result<Order, NotFoundError, ConflictError> |
VoidResult<E> | Operation can fail but returns nothing on success | VoidResult<ValidationError> |
Maybe<T> | Absence is normal, not an error | Maybe<CachedValue> |
Single-Error vs Multi-Error Result
Section titled “Single-Error vs Multi-Error Result”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>
| Scenario | Choose | Signature |
|---|---|---|
| Get user by ID | Single error | Result<User, NotFoundError> |
| Validate input (multiple fields) | Single error (aggregate) | Result<T, ValidationError> |
| Get user + check permission | Multi-error (2) | Result<User, NotFoundError, ForbiddenError> |
| Update 3 entities in parallel | AggregateError | results.CollectAll() |
| Cache lookup (miss is normal) | Maybe | Maybe<CachedValue> |
Maybe vs Result
Section titled “Maybe vs Result”- Maybe — Absence is NOT exceptional (cache miss, optional config, TryParse)
- Result — Absence is SEMANTIC (entity should exist, 404 is meaningful)
JSON Serialization
Section titled “JSON Serialization”Result types include built-in System.Text.Json converters via ResultJsonConverterFactory.
Registration
Section titled “Registration”// ASP.NET Corebuilder.Services.ConfigureHttpJsonOptions(options =>{ options.SerializerOptions.Converters.Add(new ResultJsonConverterFactory());});
// Standalonevar options = new JsonSerializerOptions();options.Converters.Add(new ResultJsonConverterFactory());Format
Section titled “Format”// 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> — NonenullPerformance
Section titled “Performance”All Result types are designed for zero-allocation operation on the happy path.
Design decisions
Section titled “Design decisions”| Decision | Impact |
|---|---|
readonly struct for all types | No heap allocation for the Result wrapper itself |
[MethodImpl(AggressiveInlining)] on hot-path methods | JIT inlines IsSuccess, Value, Match, TryGetValue, factory methods |
| Private constructors + static factory methods | Compiler can optimize construction, prevents invalid states |
byte index for multi-error variants | Single byte discriminator instead of per-error bool fields |
[DoesNotReturn] on throw helpers | Helps JIT eliminate dead code paths after guards |
[MaybeNullWhen(false)] on TryGet out params | Enables nullable flow analysis without runtime cost |
What allocates
Section titled “What allocates”- 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 usereadonly structorrecord structfor your error types, the entire operation is allocation-free. Matchwith lambdas: May allocate a delegate if the lambda captures local state. For allocation-freeMatch, use theTryGetValue/TryGetErrorpattern or a static lambda.
Benchmarks
Section titled “Benchmarks”The Pragmatic.Result.Benchmarks project measures real performance. To run:
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.
Samples
Section titled “Samples”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).
Documentation
Section titled “Documentation”- Concepts - Architecture, design decisions, and how it all fits together
- Getting Started - Quick start guide
- API Reference - Complete method documentation
- Common Mistakes - Pitfalls and how to avoid them
- Troubleshooting - Problem/solution guide with diagnostics reference
- Migration Guide - Migrate from exceptions or other libraries
- Localization - Localize error messages
EF Core Integration
Section titled “EF Core Integration”Pragmatic.Result.EFCore wraps DbContext.SaveChangesAsync() to return typed errors instead of throwing DbUpdateException:
dotnet add package Pragmatic.Result.EFCoredotnet add package Pragmatic.Result.EFCore.PostgreSQL # or .SqlServer, .Sqlite, .MySqlSaveChangesAsResultAsync
Section titled “SaveChangesAsResultAsync”// 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}"));Typed Database Errors
Section titled “Typed Database Errors”| Error Type | Maps From | HTTP Status |
|---|---|---|
DbConflictError | Unique constraint / concurrency violation | 409 |
DbConstraintError | Foreign key / check constraint violation | 400 |
DbNullConstraintError | NOT NULL violation | 400 |
DbMaxLengthError | Max length exceeded | 400 |
DbNumericOverflowError | Numeric overflow | 400 |
DbTransientError | Timeout / deadlock / connection failure | 503 |
Provider-Specific Parsing
Section titled “Provider-Specific Parsing”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 detailsservices.AddPragmaticResultEFCore() // Core .AddPostgreSqlExceptionParser(); // PostgreSQL-specific error parsing // or .AddSqlServerExceptionParser() // or .AddSqliteExceptionParser() // or .AddMySqlExceptionParser()Query Extensions
Section titled “Query Extensions”// Find by ID — returns Result<T, NotFoundError> instead of nullvar user = await context.Users.FindAsResultAsync<User, int>(42);
// FirstOrDefault — returns Result<T, NotFoundError>var order = await context.Orders .Where(o => o.CustomerId == customerId) .FirstOrDefaultAsResultAsync();Testing
Section titled “Testing”[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);}Diagnostics
Section titled “Diagnostics”The Pragmatic.Result.Analyzers package includes a Roslyn analyzer for safe Result usage:
| ID | Severity | Description |
|---|---|---|
| PRAG0001 | Warning | Unsafe Result.Value access without IsSuccess/IsFailure check |
Safe Patterns (no warning)
Section titled “Safe Patterns (no warning)”// Guard with ifif (result.IsSuccess) Console.WriteLine(result.Value);
// Guard with ternaryvar value = result.IsSuccess ? result.Value : defaultValue;
// Guard return patternif (!result.IsSuccess) return;var value = result.Value;
// Use Match (no .Value needed)var message = result.Match( value => $"Got: {value}", error => $"Error: {error}");OpenAPI Integration
Section titled “OpenAPI Integration”Pragmatic.Result.AspNetCore ships two built-in OpenAPI enrichers for richer API documentation.
builder.Services.AddOpenApi(options =>{ options.AddResultTypeSupport(); // Registers ErrorSchemaEnricher + CommonSchemaEnricher});ErrorSchemaEnricher
Section titled “ErrorSchemaEnricher”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" } }}CommonSchemaEnricher
Section titled “CommonSchemaEnricher”Adds OpenAPI format annotations to common .NET types:
| .NET Type | OpenAPI Format |
|---|---|
Guid | uuid |
DateTime / DateTimeOffset | date-time |
DateOnly | date |
Enum (with JsonStringEnumConverter) | String enum with all values listed |
Enum Values in OpenAPI
Section titled “Enum Values in OpenAPI”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"] }}Requirements
Section titled “Requirements”- .NET 10.0 or later
License
Section titled “License”MIT