A lightweight functional programming library for .NET providing Result, Maybe, and OneOf types for railway-oriented programming and error handling.
$ dotnet add package UnambitiousFx.FunctionalA lightweight, modern functional programming library for .NET that makes error handling and optional values elegant and type-safe.
Result<T> - Railway-oriented programming for error handling without exceptionsMaybe<T> - Type-safe optional values, no more null reference exceptionsOneOf<T1..T10> - Discriminated unions via source generatorsValidationError, NotFoundError, ConflictError, UnauthorizedError, and moreTask<T> and ValueTask<T>dotnet add package UnambitiousFx.Functional
For ASP.NET Core integration:
dotnet add package UnambitiousFx.Functional.AspNetCore
For xUnit testing utilities:
dotnet add package UnambitiousFx.Functional.xunit
Instead of throwing exceptions:
using UnambitiousFx.Functional;
// Traditional approach with exceptions
public User GetUser(int id)
{
var user = _repository.Find(id);
if (user is null)
throw new NotFoundException($"User {id} not found");
return user;
}
// Functional approach with Result<T>
public Result<User> GetUser(int id)
{
var user = _repository.Find(id);
return user is not null
? Result.Success(user)
: Result.Failure<User>(new NotFoundError($"User {id} not found"));
}
var result = GetUser(42);
result.Match(
success: user => Console.WriteLine($"Found: {user.Name}"),
failure: error => Console.WriteLine($"Error: {error.Message}")
);
public Result<Order> CreateOrder(int userId, OrderRequest request)
{
return GetUser(userId)
.Ensure(user => user.IsActive, new ValidationError("User is not active"))
.Bind(user => ValidateOrder(request))
.Bind(validOrder => SaveOrder(validOrder))
.Tap(order => _eventBus.Publish(new OrderCreated(order.Id)))
.WithMetadata("userId", userId);
}
public Maybe<User> FindUserByEmail(string email)
{
var user = _repository.FindByEmail(email);
return user is not null
? Maybe.Some(user)
: Maybe.None<User>();
}
// Pattern matching
var maybeUser = FindUserByEmail("user@example.com");
maybeUser.Match(
some: user => Console.WriteLine($"Found: {user.Name}"),
none: () => Console.WriteLine("User not found")
);
// Convert to Result
var result = maybeUser.ToResult(new NotFoundError("User not found"));
// Represent a value that can be one of several types
public OneOf<Success, ValidationError, NotFoundError> ProcessRequest(int id)
{
if (id <= 0)
return new ValidationError("Invalid ID");
var item = _repository.Find(id);
if (item is null)
return new NotFoundError($"Item {id} not found");
return new Success();
}
// Pattern match on all cases
var response = ProcessRequest(id);
response.Match(
first: success => Ok(),
second: validation => BadRequest(validation.Message),
third: notFound => NotFound(notFound.Message)
);
// Map - Transform success value
Result<int> result = Result.Success(5);
Result<int> doubled = result.Map(x => x * 2); // Success(10)
// Bind - Chain operations that return Result
Result<User> userResult = GetUser(id);
Result<Order> orderResult = userResult.Bind(user => CreateOrder(user));
// Flatten - Unwrap nested results
Result<Result<int>> nested = Result.Success(Result.Success(42));
Result<int> flat = nested.Flatten(); // Success(42)
// Recover - Provide fallback on error
Result<User> result = GetUser(id)
.Recover(error => GetDefaultUser());
// MapError - Transform error
Result<User> result = GetUser(id)
.MapError(error => new CustomError(error.Message));
// Ensure - Add validation
Result<User> result = GetUser(id)
.Ensure(user => user.Age >= 18, new ValidationError("Must be 18+"));
// Tap - Execute side effect on success
Result<Order> result = CreateOrder(request)
.Tap(order => _logger.LogInformation($"Order {order.Id} created"));
// TapError - Execute side effect on failure
Result<Order> result = CreateOrder(request)
.TapError(error => _logger.LogError($"Failed: {error.Message}"));
// TapBoth - Execute side effect regardless of result
Result<Order> result = CreateOrder(request)
.TapBoth(
success: order => _metrics.RecordSuccess(),
failure: error => _metrics.RecordFailure()
);
// TryGet - Safe value extraction
if (result.TryGet(out var value, out var error))
{
Console.WriteLine($"Success: {value}");
}
else
{
Console.WriteLine($"Error: {error.Message}");
}
// ValueOr - Provide default value
var user = result.ValueOr(GetDefaultUser());
// ValueOrThrow - Get value or throw
var user = result.ValueOrThrow(); // Throws if failed
// Basic error
var error = new Error("Something went wrong");
// Validation error with field details
var validationError = new ValidationError("Invalid input", new Dictionary<string, object?>
{
["Email"] = "Invalid email format",
["Age"] = "Must be at least 18"
});
// Not found error
var notFoundError = new NotFoundError("User not found");
// Conflict error
var conflictError = new ConflictError("Email already exists");
// Unauthorized error
var unauthorizedError = new UnauthorizedError("Access denied");
// Exceptional error (wrap exceptions)
try
{
// risky operation
}
catch (Exception ex)
{
return Result.Failure(new ExceptionalError(ex));
}
// Aggregate error (multiple errors)
var errors = new List<Error>
{
new ValidationError("Invalid name"),
new ValidationError("Invalid email")
};
var aggregateError = new AggregateError(errors);
Attach contextual information to results:
var result = Result.Success(user)
.WithMetadata("requestId", requestId)
.WithMetadata("timestamp", DateTime.UtcNow)
.WithMetadata("source", "UserService");
// Access metadata
if (result.Metadata.TryGetValue("requestId", out var requestId))
{
_logger.LogInformation($"Request {requestId} completed");
}
// Fluent builder
var result = Result.Success(user)
.WithMetadata(builder => builder
.Add("requestId", requestId)
.Add("duration", stopwatch.ElapsedMilliseconds)
.Add("cacheHit", false));
All operations work seamlessly with Task<T> and ValueTask<T>:
public async Task<Result<Order>> CreateOrderAsync(int userId, OrderRequest request)
{
return await GetUserAsync(userId)
.Bind(user => ValidateOrderAsync(request))
.Bind(validOrder => SaveOrderAsync(validOrder))
.Tap(order => PublishEventAsync(order));
}
// Works with ValueTask too
public async ValueTask<Result<User>> GetUserAsync(int id)
{
var user = await _repository.FindAsync(id);
return user is not null
? Result.Success(user)
: Result.Failure<User>(new NotFoundError($"User {id} not found"));
}
using UnambitiousFx.Functional.AspNetCore;
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
// Automatically converts Result to appropriate HTTP response
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
return await _userService
.GetUserAsync(id)
.ToHttpResult(); // 200 OK or 404 Not Found
}
[HttpPost]
public async Task<IActionResult> CreateUser(CreateUserRequest request)
{
return await _userService
.CreateUserAsync(request)
.ToHttpResult(); // 200 OK, 400 Bad Request, or 409 Conflict
}
}
Configure error mapping:
services.AddResultHttp(options =>
{
options.MapError<ValidationError>(StatusCodes.Status400BadRequest);
options.MapError<NotFoundError>(StatusCodes.Status404NotFound);
options.MapError<ConflictError>(StatusCodes.Status409Conflict);
options.MapError<UnauthorizedError>(StatusCodes.Status401Unauthorized);
});
using UnambitiousFx.Functional.xunit;
[Fact]
public void CreateUser_WithValidData_ReturnsSuccess()
{
// Arrange
var request = new CreateUserRequest { Name = "John", Email = "john@example.com" };
// Act
var result = _service.CreateUser(request);
// Assert
result.Should().BeSuccess()
.Which(user => user.Name.Should().Be("John"));
}
[Fact]
public void CreateUser_WithInvalidEmail_ReturnsValidationError()
{
// Arrange
var request = new CreateUserRequest { Name = "John", Email = "invalid" };
// Act
var result = _service.CreateUser(request);
// Assert
result.Should().BeFailure()
.WithError<ValidationError>()
.Which(error => error.Message.Should().Contain("email"));
}
[Fact]
public void FindUser_WhenNotExists_ReturnsNone()
{
// Act
var maybe = _repository.FindByEmail("notfound@example.com");
// Assert
maybe.Should().BeNone();
}
This library embraces:
We take performance seriously. Check our benchmarks comparing against:
Key highlights:
readonly struct value types minimize heap pressureWe welcome contributions! Please read our Contributing Guide for details on:
Check out our good first issues to get started!
See the Releases page on GitHub for detailed release notes and version history: https://github.com/UnambitiousFx/Functional/releases
This project is licensed under the MIT License - see the LICENSE file for details.
Inspired by:
Made with ❤️ by the UnambitiousFx team