A robust, type-safe implementation of the Result pattern for .NET 8.0, providing functional error handling without exceptions. Supports Map, Bind, LINQ, async operations, and full JSON serialization.
$ dotnet add package NextChapterDigital.ResultsA robust, type-safe implementation of the Result pattern for .NET 8.0, providing functional error handling without exceptions.
This library implements the Result pattern (also known as Either monad) to handle operations that can succeed or fail, replacing exception-based error handling with explicit, type-safe error management.
Key Benefits:
# Via NuGet (once published)
dotnet add package NextChapterDigital.Results
# Or add to your .csproj
<PackageReference Include="NextChapterDigital.Results" Version="1.0.0" />
using NextChapterDigital.Results;
using NextChapterDigital.Results.Errors;
// Success case - multiple ways to create
Result<int> success = Result.Success(42);
Result<int> implicitSuccess = 42; // Implicit conversion
// Error case
Result<int> error = new Error
{
Category = ErrorCategory.ValidationFailed,
Reason = "Value must be positive",
Source = "Calculator"
};
// Pattern matching to handle both cases
var output = success.Match(
onSuccess: value => $"Result: {value}",
onError: error => $"Error: {error.Reason}"
);
public class UserService
{
public async Task<Result<User>> CreateUserAsync(CreateUserRequest request)
{
return await ValidateRequest(request)
.Bind(HashPassword)
.BindAsync(SaveUserAsync)
.TapAsync(user => LogUserCreatedAsync(user))
.MapAsync(user => AddDefaultPermissions(user));
}
private Result<CreateUserRequest> ValidateRequest(CreateUserRequest request)
{
if (string.IsNullOrEmpty(request.Email))
return Error.Create().ValidationFailed("Email is required", "UserService");
if (request.Password.Length < 8)
return Error.Create().ValidationFailed("Password must be at least 8 characters", "UserService");
return request;
}
private Result<CreateUserRequest> HashPassword(CreateUserRequest request)
{
try
{
request.Password = _hasher.Hash(request.Password);
return request;
}
catch (Exception ex)
{
return Error.Create(ex).General("Failed to hash password", "UserService");
}
}
private async Task<Result<User>> SaveUserAsync(CreateUserRequest request)
{
var user = await _repository.SaveAsync(request);
return user ?? Error.Create().General("Failed to save user", "UserService");
}
}
Result<T> - Generic ResultRepresents an operation that returns a value on success or an error on failure.
Result<string> GetUserName(int userId)
{
var user = _repository.FindUser(userId);
if (user == null)
return Error.Create().NotFound($"User {userId} not found", "UserService");
return user.Name; // Implicit conversion
}
Result - Non-Generic ResultRepresents an operation that succeeds or fails without returning a value.
Result DeleteUser(int userId)
{
var user = _repository.FindUser(userId);
if (user == null)
return Error.Create().NotFound($"User {userId} not found", "UserService");
_repository.Delete(user);
return Result.Success();
}
ErrorCategory.General // General errors (1000)
ErrorCategory.NotFound // Resource not found (1001)
ErrorCategory.NoResults // No results returned (1002)
ErrorCategory.Unauthorized // Authorization required (1003)
ErrorCategory.Conflict // Conflict occurred (1004)
ErrorCategory.ValidationFailed // Validation failed (1005)
// Automatic caller information tracking
var error = Error.Create().NotFound("User not found", "UserService");
// Error will contain:
// - Member: Method name where Error.Create() was called
// - FilePath: Source file path
// - LineNumber: Line number
// - Exception: Optional exception reference
// Custom properties
var error = Error.Create().ValidationFailed(
"Invalid email format",
"EmailValidator",
new Dictionary<string, object?>
{
["Email"] = email,
["ValidationRule"] = "EmailFormat"
}
);
public static class CustomErrors
{
public static readonly ErrorCategory PaymentFailed =
ErrorCategory.General.SubCategory(
"PaymentFailed",
"domain/payment-failed",
"Payment processing failed"
);
public static Error InsufficientFunds(this ErrorLocation location, decimal required)
{
return location.Custom(
PaymentFailed,
$"Insufficient funds. Required: {required:C}",
"PaymentService",
new Dictionary<string, object?> { ["RequiredAmount"] = required }
);
}
}
Result<int> number = Result.Success(42);
Result<string> text = number.Map(n => n.ToString()); // Result<string> = "42"
// Async version
Result<string> textAsync = await number.MapAsync(async n =>
{
await LogAsync(n);
return n.ToString();
});
Result<string> input = Result.Success("123");
Result<string> result = input
.Bind(ParseInt) // Result<int>
.Bind(ValidatePositive) // Result<int>
.Map(n => n * 2) // Result<int>
.Map(n => n.ToString()); // Result<string>
Result<int> ParseInt(string s) =>
int.TryParse(s, out var n)
? n
: Error.Create().ValidationFailed("Invalid integer", "Parser");
Result<int> ValidatePositive(int n) =>
n > 0
? n
: Error.Create().ValidationFailed("Must be positive", "Validator");
// Execute logging without changing the result
var result = await GetUserAsync(userId)
.Tap(user => _logger.LogInformation("User found: {Name}", user.Name))
.TapError(error => _logger.LogError("User lookup failed: {Reason}", error.Reason))
.MapAsync(user => EnrichUserDataAsync(user));
var result = from user in GetUser(userId)
from permissions in GetUserPermissions(user.Id)
from profile in GetUserProfile(user.Id)
select new UserViewModel
{
User = user,
Permissions = permissions,
Profile = profile
};
// Equivalent to:
var result = GetUser(userId)
.Bind(user => GetUserPermissions(user.Id)
.Bind(permissions => GetUserProfile(user.Id)
.Map(profile => new UserViewModel
{
User = user,
Permissions = permissions,
Profile = profile
})));
// Convert exceptions to Results
var result = await FetchDataAsync()
.ToResult(location => Error.Create(location.Exception).General(
"Failed to fetch data",
"DataService"
));
// Simple version with default error
var result = await FetchDataAsync().ToResult();
// All-or-nothing: Returns first error or list of all successes
var userIds = new[] { 1, 2, 3, 4, 5 };
Result<List<User>> users = userIds
.Select(id => GetUser(id))
.Flatten();
users.Switch(
onSuccess: list => Console.WriteLine($"Loaded {list.Count} users"),
onError: error => Console.WriteLine($"Failed: {error.Reason}")
);
public async Task<Result<OrderConfirmation>> ProcessOrderAsync(Order order)
{
return await ValidateOrder(order)
.BindAsync(CheckInventoryAsync)
.BindAsync(ProcessPaymentAsync)
.TapAsync(order => NotifyWarehouseAsync(order))
.MapAsync(CreateConfirmationAsync)
.TapAsync(SendEmailAsync)
.TapErrorAsync(error => LogErrorAsync(error));
}
public async Task<PartialResult<T>> ProcessItemsWithPartialSuccessAsync<T>(List<T> items)
{
var results = await Task.WhenAll(items.Select(ProcessItemAsync));
return new PartialResult<T>
{
Successes = results.Where(r => r.IsSuccess())
.Select(r => r.Match(item => item, _ => default))
.ToList(),
Errors = results.Where(r => r.IsError())
.Select(r => r.Match(_ => default, error => error))
.ToList()
};
}
Results automatically serialize to JSON with type information:
// Success result
var success = Result.Success(new User { Name = "John", Age = 30 });
var json = JsonSerializer.Serialize(success);
// {"type":"value","value":{"name":"John","age":30}}
// Error result
var error = Result.Error<User>(Error.Create().NotFound("User not found"));
var errorJson = JsonSerializer.Serialize(error);
// {"type":"error","error":{"category":{...},"reason":"User not found",...}}
// Round-trip deserialization
var deserialized = JsonSerializer.Deserialize<Result<User>>(json);
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
var result = await _userService.GetUserAsync(id);
return result.Match(
onSuccess: user => Ok(user),
onError: error => error.Category switch
{
var c when c == ErrorCategory.NotFound => NotFound(error),
var c when c == ErrorCategory.Unauthorized => Unauthorized(error),
var c when c == ErrorCategory.ValidationFailed => BadRequest(error),
_ => StatusCode(500, error)
}
);
}
[HttpPost]
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest request)
{
var result = await _userService.CreateUserAsync(request);
return result.Match(
onSuccess: user => CreatedAtAction(nameof(GetUser), new { id = user.Id }, user),
onError: error => BadRequest(error)
);
}
}
IsSuccess() - Returns true if result is successfulIsError() - Returns true if result is an errorMap<TReturn>(Func<T, TReturn>) - Transform success valueMapAsync<TReturn>(Func<T, Task<TReturn>>) - Async transformationMapError(Func<Error, Error>) - Transform errorBind<TReturn>(Func<T, Result<TReturn>>) - Chain operationsBindAsync<TReturn>(Func<T, Task<Result<TReturn>>>) - Async chainingTap(Action<T>) - Execute action on successTapError(Action<Error>) - Execute action on errorSwitch(Action<T>, Action<Error>) - Execute action based on stateMatch<TReturn>(Func<T, TReturn>, Func<Error, TReturn>) - Pattern matchingToResult() - Convert value or error to ResultFlatten() - Flatten collection of ResultsToUntyped() - Convert Result to ResultThe library includes FluentAssertions extensions for testing:
using Results.Tests.Assertions;
[Fact]
public void ValidateEmail_WithValidEmail_ShouldReturnSuccess()
{
// Arrange
var email = "test@example.com";
// Act
var result = ValidateEmail(email);
// Assert
result.Should().BeSuccess(value => value.Should().Be(email));
}
[Fact]
public void ValidateEmail_WithInvalidEmail_ShouldReturnError()
{
// Arrange
var email = "invalid";
// Act
var result = ValidateEmail(email);
// Assert
result.Should().BeFailure(error =>
{
error.Category.Should().Be(ErrorCategory.ValidationFailed);
error.Reason.Should().Contain("invalid");
});
}
The Result pattern has minimal overhead:
Benchmark comparison vs exceptions:
| Operation | Result Pattern | Exception |
|---|---|---|
| Success path | ~1 ns | ~1 ns |
| Error path | ~5 ns | ~50,000 ns |
Contributions are welcome! Please ensure:
dotnet test)MIT License - see LICENSE file for details
For issues and questions:
Built with ❤️ using functional programming principles