Lightweight result types for explicit success/failure handling in .NET applications.
$ dotnet add package BYSResultsLightweight result types for explicit success/failure handling in .NET applications.
Bind, Map, MapAsync, BindAsync, etc.Match for elegant error handlingTry and TryAsync factory methodsGetValueOr and OrElse for fallback handlingTap, TapAsync, OnSuccess, OnFailure without breaking chainsEnsure for inline condition checkingResult.Combine(...).Errors, .FirstError)Result vs. Result<T>)BYSResults provides two result types to match different operation scenarios. Understanding when to use each will make your code clearer and more intentional.
Use Result when your operation only needs to indicate success or failure (no return value needed):
public Result DeleteUser(int userId)
public Result SendEmail(string to, string subject)
public Result ValidatePassword(string password)
Use Result<T> when your operation returns a value on success:
public Result<User> GetUserById(int userId)
public Result<int> ParseInteger(string input)
public Result<decimal> CalculateTotal(Order order)
| Scenario | Type | Example Signature |
|---|---|---|
| Delete operation | Result | Result DeleteCustomer(int id) |
| Update (no return) | Result | Result UpdateSettings(Settings settings) |
| Validation (pass/fail) | Result | Result ValidateEmail(string email) |
| Send/publish | Result | Result PublishMessage(Message msg) |
| Fetch/get | Result<T> | Result<User> GetUser(int id) |
| Create (return entity) | Result<T> | Result<Order> CreateOrder(OrderDto dto) |
| Parse/transform | Result<T> | Result<int> ParseInt(string s) |
| Calculate | Result<T> | Result<decimal> CalculatePrice(Item item) |
Result<T> includes additional functional programming methods that work with values:
// Result<T> has value transformation methods
var result = Result<int>.Success(42)
.Map(x => x * 2) // Transform the value
.Bind(x => Divide(x, 2)) // Chain operations
.Ensure(x => x > 0, "Must be positive");
// Result doesn't need these because there's no value to transform
var result = Result.Success()
.Ensure(() => IsValid(), "Must be valid")
.Tap(() => LogSuccess());
Non-generic for void-like operations:
// Repository delete method - no value to return
public async Task<Result> DeleteProductAsync(int productId)
{
var product = await _context.Products.FindAsync(productId);
if (product == null)
return Result.Failure("PRODUCT_NOT_FOUND", "Product not found");
_context.Products.Remove(product);
await _context.SaveChangesAsync();
return Result.Success(); // Just confirms deletion succeeded
}
Generic for data-returning operations:
// Repository get method - returns the product
public async Task<Result<Product>> GetProductAsync(int productId)
{
var product = await _context.Products.FindAsync(productId);
return product == null
? Result<Product>.Failure("PRODUCT_NOT_FOUND", "Product not found")
: Result<Product>.Success(product); // Returns the found product
}
Think of Result as analogous to void return types, and Result<T> as analogous to typed return values:
// Traditional approach
void DeleteUser(int id) → Result DeleteUser(int id)
User GetUser(int id) → Result<User> GetUser(int id)
The key difference is that both Result and Result<T> can represent failure with error details, unlike traditional return types.
Install via .NET CLI:
dotnet add package BYSResults
Or via Package Manager Console:
Install-Package BYSResults
using BYSResults;
// Simple success/failure without a value
var r1 = Result.Success();
var r2 = Result.Failure("E001", "Something went wrong");
if (r2.IsFailure)
{
Console.WriteLine(r2.FirstError?.Message);
}
// Generic result with a value
var r3 = Result<int>.Success(42);
var r4 = Result<string>.Failure("Missing data");
if (r3.IsSuccess)
{
Console.WriteLine($"Value is {r3.Value}");
}
// Inspect errors
foreach (var err in r4.Errors)
{
Console.WriteLine(err);
}
For comprehensive, runnable examples demonstrating real-world usage patterns, see the BYSResults.Examples project.
The examples project includes:
// Converting Result to HTTP-style responses
public ApiResponse<User> GetUser(int id)
{
var result = userService.GetUserById(id);
return result.Match(
onSuccess: user => new ApiResponse<User>
{
StatusCode = 200,
Data = user
},
onFailure: errors => new ApiResponse<User>
{
StatusCode = 404,
Errors = errors.Select(e => e.Message).ToList()
}
);
}
// Repository pattern with validation and side effects
public async Task<Result<Customer>> CreateCustomerAsync(CustomerDto dto)
{
return await Result<CustomerDto>.Success(dto)
.Ensure(d => !string.IsNullOrEmpty(d.Email), "Email is required")
.Ensure(d => d.Email.Contains("@"), "Valid email is required")
.Ensure(d => d.Age >= 18, new Error("AGE_RESTRICTION", "Must be 18 or older"))
.MapAsync(async d => new Customer
{
Name = d.Name,
Email = d.Email,
Age = d.Age,
CreatedAt = DateTime.UtcNow
})
.BindAsync(async customer => await repository.SaveAsync(customer))
.TapAsync(async customer => await SendWelcomeEmailAsync(customer));
}
// Collecting all validation errors at once
public Result<RegistrationForm> ValidateRegistration(RegistrationForm form)
{
var errors = new List<Error>();
if (string.IsNullOrWhiteSpace(form.Name))
errors.Add(new Error("NAME_REQUIRED", "Name is required"));
if (string.IsNullOrWhiteSpace(form.Email))
errors.Add(new Error("EMAIL_REQUIRED", "Email is required"));
else if (!form.Email.Contains("@"))
errors.Add(new Error("EMAIL_INVALID", "Email must be valid"));
if (form.Password.Length < 8)
errors.Add(new Error("PASSWORD_TOO_SHORT", "Password must be 8+ characters"));
return errors.Any()
? Result<RegistrationForm>.Failure(errors)
: Result<RegistrationForm>.Success(form);
}
// Complex multi-stage workflow with early exit on failure
public async Task<Result<ProcessedOrder>> ProcessOrderAsync(OrderRequest request)
{
return await Result<OrderRequest>.Success(request)
.BindAsync(async r => await ValidateOrderAsync(r))
.BindAsync(async r => await CheckInventoryAsync(r))
.BindAsync(async r => await CalculatePricingAsync(r))
.BindAsync(async r => await ProcessPaymentAsync(r))
.BindAsync(async r => await CreateShipmentAsync(r))
.TapAsync(async o => await SendConfirmationEmailAsync(o))
.TapAsync(async o => await LogOrderAsync(o));
}
// Handling external API calls with error handling
public async Task<Result<WeatherData>> GetWeatherAsync(string city)
{
return await Result<string>.Success(city)
.Ensure(c => !string.IsNullOrWhiteSpace(c), "City is required")
.MapAsync(async c => await FetchWeatherJsonAsync(c))
.MapAsync(async json => await DeserializeWeatherAsync(json))
.Ensure(data => data != null, "Failed to parse weather data")
.Ensure(data => data.Temperature > -100 && data.Temperature < 100,
"Invalid temperature reading");
}
// Try primary source, fall back to cache, then default
public async Task<Result<Configuration>> LoadConfigurationAsync()
{
return await LoadFromRemoteAsync()
.OrElse(async () => await LoadFromDatabaseAsync())
.OrElse(async () => await LoadFromFileAsync())
.OrElse(() => Result<Configuration>.Success(GetDefaultConfiguration()));
}
// Automatic retry with exponential backoff
public async Task<Result<T>> RetryAsync<T>(
Func<Task<Result<T>>> operation,
int maxRetries = 3)
{
Result<T>? lastResult = null;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
lastResult = await operation();
if (lastResult.IsSuccess)
return lastResult;
Console.WriteLine($"Attempt {attempt} failed. Retrying...");
await Task.Delay(TimeSpan.FromSeconds(attempt)); // Exponential backoff
}
return lastResult!.AddError(
new Error("MAX_RETRIES", $"Failed after {maxRetries} attempts"));
}
// Multi-level approval workflow
public Result<ApprovalResult> ProcessApprovalWorkflow(ApprovalRequest request)
{
return Result<ApprovalRequest>.Success(request)
.Ensure(r => r.Amount > 0, "Amount must be positive")
.Bind(r => r.Amount < 1000
? Result<ApprovalRequest>.Success(r) // Auto-approve
: GetManagerApproval(r))
.Bind(r => r.Amount < 5000
? Result<ApprovalRequest>.Success(r)
: GetDirectorApproval(r))
.Map(r => new ApprovalResult
{
RequestId = r.RequestId,
Approved = true
});
}
cd BYSResults.Examples
dotnet run
See the Examples README for detailed explanations of each pattern.
This section provides detailed API documentation for both Result and Result<T>. For guidance on choosing between the two types, see Choosing Between Result and Result<T>.
Quick reminder:
Result - For operations that only indicate success/failure (no return value)Result<T> - For operations that return a value on successResult.Result (overloads: Error, IEnumerable<Error>, string, (code, message)).Result instances into one, aggregating errors.Result.Result.Result.Result<T> with the given value.Result<T> (same overloads as Result).Note:
Result<T>.Combine()was removed in v1.2.1. UseResult.Combine(...)from the base class instead, which accepts bothResultandResult<T>instances.
Result<TNext>..Value on an existing successful result.Result<T> for chaining).Result<T>).string Code Error code (optional).
string Message Human-readable error message.
override string ToString()
Returns "Code: Message" or just "Message" if no code.
Equality operators
==, != for value equality.
// Handle both success and failure cases
var result = Result<int>.Try(() => int.Parse(input));
var message = result.Match(
onSuccess: value => $"Parsed: {value}",
onFailure: errors => $"Failed: {errors.First().Message}"
);
// Synchronous
var result = Result<int>.Try(() => RiskyOperation());
// Asynchronous
var asyncResult = await Result<string>.TryAsync(async () => await FetchDataAsync());
// Simple fallback
int value = result.GetValueOr(0);
// Lazy fallback
int value = result.GetValueOr(() => ExpensiveDefault());
// Alternative result
var final = primaryResult.OrElse(fallbackResult);
var result = Result<int>.Success(42)
.Ensure(v => v > 0, "Must be positive")
.Ensure(v => v < 100, "Must be less than 100")
.Map(v => v * 2)
.Tap(v => Console.WriteLine($"Value: {v}"))
.Bind(v => AnotherOperation(v));
var result = await Result<User>.Success(userId)
.MapAsync(async id => await GetUserAsync(id))
.BindAsync(async user => await ValidateUserAsync(user))
.TapAsync(async user => await LogAsync($"User: {user.Name}"));
var result = Result<int>.Failure("Database error")
.OnFailure(errors =>
{
Logger.Log(errors);
return Result<int>.Success(GetCachedValue());
});
var combined = Result.Combine(
ValidateName(name),
ValidateEmail(email),
ValidateAge(age)
);
if (combined.IsFailure)
{
Console.WriteLine($"Validation failed: {string.Join(", ", combined.Errors)}");
}
BYSResults is designed with immutability and thread safety in mind, but there are important considerations when sharing Result instances across threads.
Immutable Components:
Reading Results:
IsSuccess, IsFailure, Value, Errors) is thread-safeMutable Error Lists:
AddError() and AddErrors() methods modify the internal error listAddError() from multiple threads can cause race conditionsRecommendations:
Avoid Mutation After Creation (Preferred)
// Good: Create result with all errors upfront
var errors = new List<Error> { error1, error2 };
var result = Result<int>.Failure(errors);
// Now safe to share across threads
Don't Share Mutable Results
// Avoid: Sharing a result while adding errors
var result = Result.Success();
Task.Run(() => result.AddError(error1)); // NOT SAFE
Task.Run(() => result.AddError(error2)); // NOT SAFE
Synchronize Mutations
// If you must mutate, use synchronization
var result = Result.Success();
var lockObj = new object();
lock (lockObj)
{
result.AddError(error1);
result.AddError(error2);
}
Pattern 1: Immutable Results
// Create results immutably and share freely
public async Task<Result<Data>> ProcessAsync()
{
var result = await FetchDataAsync();
// Once created, safe to share
return result;
}
Pattern 2: Collect Then Create
// Collect errors locally, then create result once
public Result ValidateConcurrently(IEnumerable<Item> items)
{
var errors = new ConcurrentBag<Error>();
Parallel.ForEach(items, item =>
{
if (!IsValid(item))
errors.Add(new Error($"Invalid: {item.Name}"));
});
return errors.Any()
? Result.Failure(errors)
: Result.Success();
}
Pattern 3: Task Results
// Each task creates its own result
public async Task<Result> ProcessMultipleAsync(IEnumerable<Item> items)
{
var tasks = items.Select(ProcessItemAsync);
var results = await Task.WhenAll(tasks);
// Combine results (thread-safe operation)
return Result.Combine(results);
}
| Operation | Thread-Safe? | Notes |
|---|---|---|
| Reading properties | ✓ Yes | Always safe once created |
| Creating new results | ✓ Yes | Factory methods are safe |
Map, Bind, etc. | ✓ Yes | Return new instances |
AddError() | ✗ No | Mutates internal list |
AddErrors() | ✗ No | Mutates internal list |
Combine() | ✓ Yes | Reads only, creates new result |
General Rule: Treat Result instances as immutable after creation. If you need to add errors, create the result with all errors upfront or ensure proper synchronization.
For a detailed changelog with all releases and changes, see CHANGELOG.md.
Latest Release: v1.2.3 (2025-11-02)
Fork the repository.
Create a feature branch:
git checkout -b feature/YourFeature
Commit your changes:
git commit -m "Add awesome feature"
Push to the branch:
git push origin feature/YourFeature
Open a Pull Request.
Follow the code style and include tests.
See CONTRIBUTING.md for more details.
Licensed under the MIT License. See LICENSE for details.
Thanks to all contributors.