A powerful, functional, and type-safe result type for C# that eliminates exception-based error handling and enables railway-oriented programming.
- Installation
- Overview
- Core Features
- Factory Methods
- Operators
- Functional Programming Methods
- Error Handling & Recovery
- Async Operations
- Collection Operations
- Conversion Methods
- Advanced Features
- Usage Examples
- Best Practices
# Install OneOf package (required dependency)
dotnet add package OneOfAdd the RequestResult.cs file to your project.
RequestResult<T> is a discriminated union type that represents either a successful result with a value of type T, or a failure with an Exception. This pattern eliminates the need for try-catch blocks and makes error handling explicit and composable.
// Traditional approach
try
{
var user = GetUser(id);
var orders = GetOrders(user.Id);
return ProcessOrders(orders);
}
catch (Exception ex)
{
// Handle error
}
// With RequestResult
return GetUser(id)
.Bind(user => GetOrders(user.Id))
.Map(ProcessOrders);var result = RequestResult<int>.Success(42);
bool isSuccess = result.IsSuccessful; // true
bool isFailed = result.IsFailed; // false
Exception? error = result.Error; // null
int value = result.Value; // 42 (throws if failed)// Try to get value
if (result.TryGetValue(out var value))
{
Console.WriteLine($"Success: {value}");
}
// Try to get error
if (result.TryGetError(out var error))
{
Console.WriteLine($"Error: {error.Message}");
}// Throws exception if result is failed
result.EnsureSuccess();
// Async version
await result.EnsureSuccessAsync();// Create successful result
var result = RequestResult<string>.Success("Hello");
// With nullable value
var result = RequestResult<string>.Success(null);
// Empty success (for void operations)
var result = RequestResult<object>.SuccessEmpty();// Create failed result with message
var result = RequestResult<int>.Fail("Something went wrong");
// Create failed result with exception
var result = RequestResult<int>.Fail(new InvalidOperationException("Error"));string? nullableValue = GetNullableValue();
var result = RequestResult<string>.FromNullable(
nullableValue,
"Value cannot be null"
);// With direct value
var result = RequestResult<int>.FromCondition(
age >= 18,
age,
"Must be 18 or older"
);
// With factory function
var result = RequestResult<User>.FromCondition(
IsValid(data),
() => CreateUser(data),
"Invalid user data"
);// Automatic conversion from value to successful result
RequestResult<int> result = 42;
// Automatic conversion from exception to failed result
RequestResult<int> result = new Exception("Error");// Convert to value (throws if failed)
int value = (int)result;
// Convert to nullable
int? nullable = (int?)result;
// Convert to boolean
bool isSuccess = (bool)result;// OR - returns first successful result
var result = TryPrimary() | TrySecondary() | TryFallback();
// AND - returns first failure or last success
var result = ValidateStep1() & ValidateStep2() & ValidateStep3();
// Negation - inverts success/failure
var inverted = !result;// Use directly in if statements
if (result)
{
// Executes if successful
Console.WriteLine("Success!");
}Transforms the value if successful, propagates errors.
RequestResult<int> number = RequestResult<int>.Success(5);
RequestResult<string> text = number.Map(n => $"Number: {n}");
// Result: Success("Number: 5")
RequestResult<string> textAsync = await number.MapAsync(async n =>
{
await Task.Delay(100);
return $"Number: {n}";
});Chains operations that return RequestResult.
RequestResult<User> user = GetUser(id);
RequestResult<Order[]> orders = user.Bind(u => GetOrders(u.Id));
// Async version
RequestResult<Order[]> ordersAsync = await user.BindAsync(async u =>
await GetOrdersAsync(u.Id)
);Validates the value with a predicate.
var result = RequestResult<int>.Success(15)
.Filter(n => n > 10, "Number must be greater than 10");
// Result: Success(15)
var failed = RequestResult<int>.Success(5)
.Filter(n => n > 10, "Number must be greater than 10");
// Result: Fail("Number must be greater than 10")
// Async version
var resultAsync = await value.FilterAsync(
async v => await ValidateAsync(v),
"Validation failed"
);Performs side effects without changing the result.
var result = GetUser(id)
.Tap(user => Console.WriteLine($"Found user: {user.Name}"))
.Tap(user => LogAccess(user.Id))
.Map(user => user.Email);
// Async version
var resultAsync = await GetUserAsync(id)
.TapAsync(async user => await LogUserAccessAsync(user));Performs side effects on errors.
var result = GetUser(id)
.TapError(ex => Console.WriteLine($"Error: {ex.Message}"))
.TapError(ex => LogError(ex));
// Async version
await result.TapErrorAsync(async ex => await LogErrorAsync(ex));Performs side effects on both success and failure.
var result = GetUser(id)
.TapBoth(
user => Console.WriteLine($"Success: {user.Name}"),
error => Console.WriteLine($"Error: {error.Message}")
);Pattern matching for both success and failure cases.
// With return value
string message = result.Match(
onSuccess: value => $"Success: {value}",
onFailure: error => $"Error: {error.Message}"
);
// Without return value (Action)
result.Match(
onSuccess: value => Console.WriteLine($"Got: {value}"),
onFailure: error => LogError(error)
);
// Async version
var message = await result.MatchAsync(
onSuccess: async value => await FormatAsync(value),
onFailure: async error => await FormatErrorAsync(error)
);Gets value or provides a default.
// With direct default value
int value = result.ValueOr(0);
// With factory function
int value = result.ValueOr(() => GetDefaultValue());
// With error-based default
int value = result.ValueOr(error =>
{
LogError(error);
return -1;
});Recovers from failure by providing a fallback value.
var result = GetUser(id)
.Recover(error => GetDefaultUser());
// Async version
var result = await GetUserAsync(id)
.RecoverAsync(async error => await GetDefaultUserAsync());Recovers with a fallback RequestResult.
var result = GetFromCache(key)
.RecoverWith(error => GetFromDatabase(key))
.RecoverWith(error => GetFromBackup(key));
// Async version
var result = await GetFromCacheAsync(key)
.RecoverWithAsync(async error => await GetFromDatabaseAsync(key));Transforms exceptions.
var result = GetData()
.MapError(ex => new CustomException("Failed to get data", ex));Automatically retries failed operations.
var result = await RequestResult<Data>.RetryAsync(
operation: async () => await FetchDataAsync(),
maxAttempts: 5,
delayBetweenAttempts: TimeSpan.FromSeconds(1)
);Executes operation with timeout.
var result = await RequestResult<Data>.WithTimeoutAsync(
operation: async () => await SlowOperationAsync(),
timeout: TimeSpan.FromSeconds(30)
);Combines two results.
var userResult = GetUser(userId);
var ordersResult = GetOrders(userId);
var combined = userResult.Zip(
ordersResult,
(user, orders) => new { User = user, Orders = orders }
);
// Async version
var combined = await userResult.ZipAsync(
GetOrdersAsync(userId),
(user, orders) => new { User = user, Orders = orders }
);Combines multiple results into one. Fails if any result fails.
var results = new[]
{
RequestResult<int>.Success(1),
RequestResult<int>.Success(2),
RequestResult<int>.Success(3)
};
RequestResult<IEnumerable<int>> combined = results.Combine();
// Result: Success([1, 2, 3])Combines all results, collecting all errors if any fail.
var results = new[]
{
RequestResult<int>.Success(1),
RequestResult<int>.Fail("Error 1"),
RequestResult<int>.Fail("Error 2")
};
var combined = results.CombineAll();
// Result: Fail(AggregateException with both errors)Splits results into successes and failures.
var results = new[]
{
RequestResult<int>.Success(1),
RequestResult<int>.Fail("Error"),
RequestResult<int>.Success(2)
};
var (successes, failures) = results.Partition();
// successes: [1, 2]
// failures: [Exception("Error")]Applies async function to collection, stops on first failure.
var userIds = new[] { 1, 2, 3, 4, 5 };
var result = await userIds.TraverseAsync(async id =>
await GetUserAsync(id)
);
// Result: RequestResult<IEnumerable<User>>Processes all items in parallel, collects all errors.
var userIds = new[] { 1, 2, 3, 4, 5 };
var result = await userIds.TraverseAllAsync(async id =>
await GetUserAsync(id)
);Executes operations in sequence until one fails.
var operations = new Func<Task<RequestResult<Data>>>[]
{
async () => await Step1Async(),
async () => await Step2Async(),
async () => await Step3Async()
};
var result = await operations.SequenceAsync();int? nullable = result.ToNullable();
// Returns value if successful, null if failedvar (value, error) = result.ToTuple();Either<Exception, int> either = result.ToEither();var (isSuccess, value, error) = result;
if (isSuccess)
{
Console.WriteLine($"Value: {value}");
}string simple = result.ToString();
// "Success: 42" or "Error: Something went wrong"
string detailed = result.ToDetailedString();
// Includes stack trace for errorsWraps operations in try-catch automatically.
// Synchronous
var result = RequestResultExtensions.Try(() =>
{
return RiskyOperation();
});
// Async
var result = await RequestResultExtensions.TryAsync(async () =>
{
return await RiskyOperationAsync();
});
// For void operations (returns RequestResult<Unit>)
var result = RequestResultExtensions.Try(() =>
{
PerformAction();
});
var result = await RequestResultExtensions.TryAsync(async () =>
{
await PerformActionAsync();
});Converts Task to RequestResult with exception handling.
Task<User> userTask = GetUserAsync(id);
RequestResult<User> result = await userTask.ToRequestResultAsync();Unwraps nested RequestResults.
RequestResult<RequestResult<int>> nested = GetNestedResult();
RequestResult<int> flattened = nested.Flatten();public RequestResult<User> GetUser(int id)
{
if (id <= 0)
return RequestResult<User>.Fail("Invalid ID");
var user = _repository.Find(id);
return RequestResult<User>.FromNullable(user, "User not found");
}public async Task<RequestResult<OrderSummary>> ProcessOrder(int orderId)
{
return await GetOrder(orderId)
.Bind(order => ValidateOrder(order))
.BindAsync(async order => await ChargePayment(order))
.MapAsync(async order => await CreateSummary(order))
.TapAsync(async summary => await SendConfirmationEmail(summary))
.RecoverAsync(async error => await HandleError(error));
}public RequestResult<User> ValidateUser(UserInput input)
{
return RequestResult<User>.Success(new User(input))
.Filter(u => !string.IsNullOrEmpty(u.Email), "Email is required")
.Filter(u => u.Age >= 18, "Must be 18 or older")
.Filter(u => IsValidEmail(u.Email), "Invalid email format");
}public async Task<RequestResult<Dashboard>> GetDashboard(int userId)
{
var userTask = GetUserAsync(userId);
var ordersTask = GetOrdersAsync(userId);
var settingsTask = GetSettingsAsync(userId);
var userResult = await userTask.ToRequestResultAsync();
return await userResult.ZipAsync(
ordersTask.ToRequestResultAsync(),
(user, orders) => new { user, orders }
).ZipAsync(
settingsTask.ToRequestResultAsync(),
(data, settings) => new Dashboard
{
User = data.user,
Orders = data.orders,
Settings = settings
}
);
}public async Task<RequestResult<Data>> GetData(string key)
{
return await RequestResult<Data>.RetryAsync(
async () => await GetFromPrimarySource(key),
maxAttempts: 3,
delayBetweenAttempts: TimeSpan.FromSeconds(2)
)
.RecoverWithAsync(async error =>
await GetFromBackupSource(key)
)
.RecoverAsync(async error =>
await GetDefaultData()
);
}public async Task<RequestResult<Report>> GenerateReport(int[] userIds)
{
var results = await userIds.TraverseAsync(async id =>
await GetUserData(id)
);
return results.Map(userData => CreateReport(userData));
}public async Task<IActionResult> GetUserEndpoint(int id)
{
var result = await GetUser(id);
return result.Match(
onSuccess: user => Ok(user),
onFailure: error => error switch
{
NotFoundException => NotFound(error.Message),
UnauthorizedException => Unauthorized(),
_ => StatusCode(500, error.Message)
}
);
}// Try multiple sources
var data = GetFromCache(key)
| GetFromDatabase(key)
| GetDefaultData();
// Validate multiple conditions
var result = CheckAge()
& CheckEmail()
& CheckPhone();
// Simple conditional
RequestResult<int> result = GetValue();
if (result)
{
ProcessValue(result.Value);
}// ❌ Bad - ignores errors
var value = result.Value;
// ✅ Good - handles both cases
result.Match(
onSuccess: value => ProcessValue(value),
onFailure: error => LogError(error)
);// ❌ Bad - nested results
var result = GetUser(id);
if (result.IsSuccessful)
{
var orders = GetOrders(result.Value.Id);
if (orders.IsSuccessful)
{
return orders;
}
}
// ✅ Good - flat chain
return GetUser(id)
.Bind(user => GetOrders(user.Id));// ❌ Bad - breaks the chain
var result = GetUser(id);
if (result.IsSuccessful)
{
LogAccess(result.Value);
}
return result.Map(user => user.Email);
// ✅ Good - preserves chain
return GetUser(id)
.Tap(user => LogAccess(user))
.Map(user => user.Email);// ✅ Good - clear flow
return ValidateInput(request)
.Bind(data => SaveToDatabase(data))
.MapAsync(async entity => await EnrichData(entity))
.Tap(result => PublishEvent(result))
.Map(entity => MapToDto(entity));// ✅ Good - automatic exception handling
var result = await RequestResultExtensions.TryAsync(async () =>
await externalApi.GetDataAsync()
);For operations that don't return a value, use Unit:
public RequestResult<Unit> DeleteUser(int id)
{
_repository.Delete(id);
return RequestResult<Unit>.Success(Unit.Value);
}
// Or use extension method
var result = RequestResultExtensions.Try(() =>
{
PerformAction();
});MIT
Contributions welcome! Please feel free to submit a Pull Request.
Built with ❤️ using OneOf library