High-performance, struct-based Result monad for railway-oriented programming in C#. Features comprehensive async support (ValueTask + Task), validation (Ensure), inspection (Inspect), and complete monad operations (Map, Then, Match).
$ dotnet add package Cirreum.ResultA lightweight, allocation‑free, struct‑based Result and Optional monad library designed for high‑performance .NET applications.
Provides a complete toolkit for functional, exception‑free control flow with full async support, validation, inspection, monadic composition, and pagination result types.
Result and Result<T>.Optional<T> for explicit presence/absence modeling.SliceResult<T>, CursorResult<T>, and PagedResult<T> for consistent data access patterns.ValueTask + TaskMap, Then, Ensure, Inspect, failure handlers, etc.Ensure:
Inspect, InspectTry.Map, Then, Match, Switch, TryGetValue, TryGetError, and more.dotnet add package Cirreum.Result
🧱 Target Frameworks
Result is built for modern, high-performance .NET, with first-class support for:
| TFM | Status | Notes |
|---|---|---|
| .NET 10 | ✔️ Primary | Latest runtime/JIT optimizations. Best performance. |
| .NET 9 | ✔️ Supported | Fully compatible. Same performance envelope for most scenarios. |
| .NET 8 (LTS) | ✔️ Supported | Ideal for production stability; fully compatible. |
Result SaveUser(User user)
{
if (user is null)
return Result.Fail(new ArgumentNullException(nameof(user)));
return Result.Success;
}
Result<User> CreateUser(string name)
{
if (string.IsNullOrWhiteSpace(name))
return Result<User>.Fail(new ArgumentException("Name is required"));
return Result<User>.Success(new User(name));
}
Then and Mapvar result =
CreateUser("Alice")
.Map(user => user.Id)
.Then(LogCreation);
The non-generic Result class provides convenient factory methods:
// Create a successful Result<T>
var success = Result.From(42); // Result<int>
// Create a failed Result<T>
var failure = Result.Fail<string>(new InvalidOperationException("Error"));
// Convert Optional<T> to Result<T>
var optional = Optional<User>.From(user);
// With direct exception
var result1 = Result.FromOptional(optional, new NotFoundException("User not found"));
// With error message (creates InvalidOperationException)
var result2 = Result.FromOptional(optional, "User not found");
// With error factory for lazy evaluation
var result3 = Result.FromOptional(optional, () => new NotFoundException($"User {id} not found"));
Use Optional<T> when a value may be absent without implying an error:
// When you know the value is not null
var name = Optional<string>.For("John");
// When the value might be null (null-safe)
var user = _db.Users.Find(id);
var userOptional = Optional.From(user); // Returns Empty if null
// Explicit empty
var empty = Optional<int>.Empty;
Optional<User> FindUser(int id)
{
var user = _db.Users.Find(id);
return Optional.From(user); // Returns Empty if null
}
// Chaining
var displayName = FindUser(id)
.Map(u => u.DisplayName)
.GetValueOrDefault("Unknown");
// Pattern matching
FindUser(id).Switch(
onValue: user => Console.WriteLine($"Found: {user.Name}"),
onEmpty: () => Console.WriteLine("User not found"));
// Convert to Result when absence is an error
var result = FindUser(id)
.ToResult(new NotFoundException("User not found"));
// Or use the static factory method
var result2 = Result.FromOptional(FindUser(id), new NotFoundException("User not found"));
Optional<T> vs Result<T>| Use Case | Type |
|---|---|
| Operation that might fail with a reason | Result<T> |
| Value that may or may not exist | Optional<T> |
| Dictionary/cache lookup | Optional<T> |
| Database query that might return null | Optional<T> |
| Validation or business rule failure | Result<T> |
| API call that might error | Result<T> |
Three result types provide consistent contracts for paginated data across any persistence implementation (SQL, NoSQL, APIs, etc.):
The simplest pagination type—just items and a "has more" indicator. Ideal for "load more" buttons or batch processing.
// Creating a slice
var slice = new SliceResult<Order>(orders, hasMore: true);
// Empty slice
var empty = SliceResult<Order>.Empty;
// Transform items while preserving metadata
var dtos = slice.Map(order => new OrderDto(order));
// Check state
if (slice.IsEmpty) { /* handle empty */ }
if (slice.HasMore) { /* show "Load More" button */ }
Cursor-based (keyset) pagination for stable results across data changes. Ideal for infinite scroll, real-time data, and large datasets.
// Creating a cursor result
var result = new CursorResult<Order>(orders, nextCursor: "abc123", hasNextPage: true) {
PreviousCursor = "xyz789",
TotalCount = 1000 // Optional
};
// Empty result
var empty = CursorResult<Order>.Empty;
// Transform items
var dtos = result.Map(order => new OrderDto(order));
// Navigation
if (result.HasNextPage) { /* use result.NextCursor */ }
if (result.HasPreviousPage) { /* use result.PreviousCursor */ }
Offset-based pagination with full metadata. Ideal for traditional paged UIs with page numbers.
// Creating a paged result
var result = new PagedResult<Order>(orders, totalCount: 100, pageSize: 25, pageNumber: 1);
// Empty result with custom page size
var empty = PagedResult<Order>.Empty(pageSize: 50);
// Transform items
var dtos = result.Map(order => new OrderDto(order));
// Navigation and metadata
Console.WriteLine($"Page {result.PageNumber} of {result.TotalPages}");
Console.WriteLine($"Showing {result.Count} of {result.TotalCount} items");
if (result.HasNextPage) { /* show next button */ }
if (result.HasPreviousPage) { /* show previous button */ }
| Use Case | Type | Why |
|---|---|---|
| "Load more" button | SliceResult<T> | Simple, no count query needed |
| Infinite scroll | CursorResult<T> | Stable with data changes |
| Large datasets | CursorResult<T> | Consistent performance at any depth |
| Real-time data | CursorResult<T> | No shifting results |
| Traditional paged UI | PagedResult<T> | Users expect page numbers |
| Small datasets with page jumps | PagedResult<T> | Random page access needed |
| Batch processing | SliceResult<T> | Minimal overhead |
All pagination types support Map() for clean DTO projections:
// Repository returns domain entities
public async Task<PagedResult<Order>> GetOrdersAsync(int page, int pageSize)
{
// ... query implementation
}
// Service transforms to DTOs
public async Task<PagedResult<OrderDto>> GetOrderDtosAsync(int page, int pageSize)
{
var orders = await _repository.GetOrdersAsync(page, pageSize);
return orders.Map(order => new OrderDto(order));
}
// Controller returns the result directly
[HttpGet]
public async Task<PagedResult<OrderDto>> GetOrders(int page = 1, int pageSize = 25)
{
return await _orderService.GetOrderDtosAsync(page, pageSize);
}
EnsureThe Ensure method provides a fluent way to add validation to your Result<T> pipeline. If the predicate returns false, the success result is converted to a failure.
// With error message (creates InvalidOperationException)
var result = GetOrder(id)
.Ensure(o => o.Amount > 0, "Amount must be positive");
// With exception factory for custom error types
var result = GetOrder(id)
.Ensure(o => o.Amount > 0, o => new ValidationException($"Order {o.Id} has invalid amount: {o.Amount}"));
// With direct exception
var result = GetOrder(id)
.Ensure(o => o.Amount > 0, new ValidationException("Amount must be positive"));
// Chain multiple validations - stops on first failure
var result = GetOrder(id)
.Ensure(o => o.Amount > 0, "Amount must be positive")
.Ensure(o => o.Items.Any(), "Order must have items")
.Ensure(o => o.CustomerId != null, o => new InvalidOperationException($"Order {o.Id} has no customer"));
// Async predicate with error factory
var result = await GetOrderAsync(id)
.EnsureAsync(async o => await IsValidCustomer(o.CustomerId),
o => new ValidationException($"Invalid customer: {o.CustomerId}"));
// Async predicate with direct exception
var result = await GetOrderAsync(id)
.EnsureAsync(async o => await HasSufficientStock(o.Items),
new InsufficientStockException());
// Mix sync and async validations
var result = await GetOrderAsync(id)
.EnsureAsync(o => o.Amount > 0, "Amount must be positive") // sync predicate
.EnsureAsync(async o => await IsValidCustomer(o.CustomerId), "Invalid customer") // async predicate
.EnsureAsync(o => o.Items.All(i => i.Quantity > 0), "All items must have positive quantity");
Inspectresult
.Inspect(r => logger.LogInformation("Result: {State}", r.IsSuccess ? "OK" : "FAIL"));
ValueTask + Task)Every operation has async variants:
var result =
await GetUserAsync(id)
.OnSuccessAsync(user => logger.LogInformation("Loaded {Id}", user.Id))
.OnFailureAsync(ex => logger.LogError(ex, "Failed to load user"));
Or with async lambdas:
await SaveAsync(entity)
.OnSuccessTryAsync(async () => await NotifyAsync(entity));
var message = result.Match(
onSuccess: () => "OK",
onFailure: ex => $"Error: {ex.Message}");
Result (non-generic)IsSuccess, IsFailure, ErrorOnSuccess, OnFailure, Inspect, TryGetErrorMap, Then, MatchFrom<T>(T value) - creates Result<T>Fail<T>(Exception error) - creates failed Result<T>FromOptional<T>(Optional<T>, Exception/string/Func<Exception>) - converts Optional<T> to Result<T>OnSuccessAsync, OnFailureAsync, SwitchAsync, etc.Result<T>All of the above, plus:
Value / TryGetValueMap<TOut>(...)Ensure(...) validation helpers:
Ensure(Func<T, bool> predicate, string errorMessage)Ensure(Func<T, bool> predicate, Exception error)Ensure(Func<T, bool> predicate, Func<T, Exception> errorFactory)Optional<T>HasValue, IsEmpty, Value, TryGetValueFor(T non-null) - creates Optional from non-null valueEmpty - static property for empty optionalMap, Then, Match, Switch, WhereGetValueOrDefault(T), GetValueOrDefault(Func<T>), GetValueOrNull()ToResult(Exception) for converting to Result<T>Optional (non-generic factory)From<T>(T?) - null-safe factory methodSliceResult<T>Items, HasMore, Count, IsEmptyEmpty - static property for empty sliceMap<TResult>(Func<T, TResult>) - transform items preserving metadataCursorResult<T>Items, NextCursor, HasNextPage, Count, IsEmptyPreviousCursor, HasPreviousPage - bidirectional navigationTotalCount - optional total countEmpty - static property for empty resultMap<TResult>(Func<T, TResult>) - transform items preserving metadataPagedResult<T>Items, TotalCount, PageSize, PageNumber, Count, IsEmptyTotalPages, HasNextPage, HasPreviousPage - computed propertiesEmpty(int pageSize = 25) - static factory for empty resultMap<TResult>(Func<T, TResult>) - transform items preserving metadata(From ResultAsyncExtensions)
Supports async versions of:
OnSuccessOnSuccessTryOnFailureOnFailureTryInspectEnsureMapThenMatchAll support both ValueTask and Task.
var result =
await Validate(request)
.Then(() => LoadUser(request.UserId))
.Ensure(u => u.IsActive, "User must be active")
.Map(user => user.Profile)
.OnSuccessAsync(profile => Cache(profile))
.OnFailureAsync(ex => LogFailure(ex));
No exceptions. No branches. Pure railway flow.
public async Task<Result<User>> RegisterUserAsync(RegistrationRequest request)
{
return await Result<RegistrationRequest>.Success(request)
.Ensure(r => !string.IsNullOrWhiteSpace(r.Email), "Email is required")
.Ensure(r => IsValidEmail(r.Email), "Invalid email format")
.Ensure(r => r.Password.Length >= 8, "Password must be at least 8 characters")
.EnsureAsync(async r => !await UserExists(r.Email),
r => new DuplicateUserException($"User with email {r.Email} already exists"))
.Map(r => new User { Email = r.Email, PasswordHash = HashPassword(r.Password) })
.ThenAsync(async user => await SaveUserAsync(user))
.OnSuccessAsync(async user => await SendWelcomeEmailAsync(user.Email))
.InspectAsync(result =>
_logger.LogInformation("Registration attempt for {Email}: {Success}",
request.Email, result.IsSuccess));
}
public async Task<Result<Order>> ProcessOrderAsync(OrderRequest request)
{
return await LoadCustomerAsync(request.CustomerId)
.Ensure(c => c.IsActive, c => new InactiveCustomerException($"Customer {c.Id} is inactive"))
.Ensure(c => !c.HasOutstandingBalance, "Customer has outstanding balance")
.Map(customer => CreateOrder(customer, request.Items))
.EnsureAsync(async order => await CheckInventoryAsync(order.Items),
"Insufficient inventory for one or more items")
.Ensure(order => order.Total >= 10m, "Minimum order amount is $10")
.ThenAsync(async order => await SaveOrderAsync(order))
.OnSuccessAsync(async order => {
await ReserveInventoryAsync(order.Items);
await NotifyWarehouseAsync(order);
})
.OnFailureAsync(async error => {
await _logger.LogErrorAsync(error, "Order processing failed");
if (error is InsufficientInventoryException)
await NotifyInventoryTeamAsync(request);
});
}
public async Task<Result<UserProfile>> GetUserProfileAsync(int userId)
{
// Find user returns Optional<User>
var userOptional = await _repository.FindUserAsync(userId);
return userOptional
.ToResult(new NotFoundException($"User {userId} not found"))
.EnsureAsync(async u => await IsAuthorizedToViewProfile(u),
new UnauthorizedException("Not authorized to view this profile"))
.ThenAsync(async user => {
var profile = await LoadProfileAsync(user.Id);
var preferences = await LoadPreferencesAsync(user.Id);
return BuildCompleteProfile(user, profile, preferences);
});
}
// Alternative using static factory
public async Task<Result<Product>> GetProductAsync(string sku)
{
var productOptional = await _cache.GetProductAsync(sku);
// Convert optional to result using factory method
return Result.FromOptional(productOptional, () => new ProductNotFoundException($"Product {sku} not found"))
.Ensure(p => p.IsAvailable, "Product is not available")
.Ensure(p => p.Stock > 0, p => new OutOfStockException($"Product {p.Name} is out of stock"))
.Map(p => ApplyCurrentPricing(p));
}
public async Task<PagedResult<OrderSummaryDto>> GetOrdersAsync(
Guid customerId,
int pageNumber = 1,
int pageSize = 25)
{
var orders = await _repository.GetOrdersByCustomerAsync(customerId, pageNumber, pageSize);
return orders.Map(order => new OrderSummaryDto {
Id = order.Id,
OrderDate = order.CreatedAt,
Total = order.Total,
Status = order.Status.ToString(),
ItemCount = order.Items.Count
});
}
// Cursor-based for infinite scroll
public async Task<CursorResult<ActivityDto>> GetActivityFeedAsync(
Guid userId,
string? cursor = null,
int pageSize = 50)
{
var activities = await _repository.GetActivitiesAsync(userId, cursor, pageSize);
return activities.Map(activity => new ActivityDto {
Id = activity.Id,
Type = activity.Type,
Description = activity.Description,
Timestamp = activity.CreatedAt
});
}
// Good - specific exception with context
.Ensure(order => order.Items.Any(),
order => new EmptyOrderException($"Order {order.Id} has no items"))
// Less ideal - generic exception
.Ensure(order => order.Items.Any(), "Order has no items")
// Good - fails fast on basic validation
result
.Ensure(x => x != null, "Value cannot be null")
.Ensure(x => x.Length > 0, "Value cannot be empty")
.Ensure(x => x.Length <= 100, "Value too long")
.EnsureAsync(async x => await IsUniqueAsync(x), "Value must be unique");
// Good
Optional<User> FindUserByEmail(string email);
Result<User> CreateUser(CreateUserRequest request);
// Avoid
Result<User> FindUserByEmail(string email); // Finding nothing isn't an error
Optional<User> CreateUser(CreateUserRequest request); // Creation can fail
// Good - clear async boundaries
var result = ProcessSync()
.Then(x => TransformSync(x))
.ThenAsync(async x => await SaveAsync(x))
.OnSuccessAsync(async x => await NotifyAsync(x));
// Avoid - mixing unnecessarily
var result = await ProcessSync()
.ThenAsync(async x => TransformSync(x)) // Wrapping sync in async
// Good - provides context
.Ensure(u => u.Age >= 18,
u => new ValidationException($"User {u.Id} is underage: {u.Age}"))
// Less helpful
.Ensure(u => u.Age >= 18, new ValidationException("User is underage"))
// Good - cursor for real-time feed
public Task<CursorResult<Post>> GetTimelineAsync(string? cursor);
// Good - paged for admin dashboard
public Task<PagedResult<User>> GetUsersAsync(int page, int pageSize);
// Good - slice for "show more" preview
public Task<SliceResult<Comment>> GetRecentCommentsAsync(int limit);
// Avoid - offset pagination for large real-time data
public Task<PagedResult<Post>> GetTimelineAsync(int page); // Results shift as new posts arrive
This project is licensed under the MIT License.
Pull requests are welcome! If you have ideas for improvements—performance tweaks, new helpers, analyzers—feel free to open an issue or contribute directly.
IResult, IResult<T>Result, Result<T>, Optional<T>SliceResult<T>, CursorResult<T>, PagedResult<T>ResultAsyncExtensionsMade with ❤️ for clean, predictable, exception‑free flow control.
Cirreum Foundation Framework
Layered simplicity for modern .NET