zentient results api reference Functional Utilities - ulfbou/Zentient.Results GitHub Wiki
One of the most powerful aspects of Zentient.Results is its emphasis on functional composition. This means you can chain operations together in a fluent, readable manner, avoiding deeply nested if/else
statements often associated with traditional error handling. These functional utilities, implemented as extension methods, allow you to transform, combine, and react to Result
types while automatically propagating failures.
They help you:
- Decouple Concerns: Separate the "what" of an operation from the "how it succeeds or fails."
- Improve Readability: Create pipelines of operations that clearly show the flow of data.
- Ensure Exhaustiveness: Encourage handling of both success and failure paths.
The Map
extension method is used to transform the value within a successful IResult<T>
from one type to another (T
to U
). If the original result is a failure, the Map
operation is skipped, and the failure is simply propagated, allowing for seamless chaining.
Think of Map
as applying a transformation function to the "inside" of a successful Result
.
Transforms IResult<T>
to IResult<U>
.
-
Signature:
public static IResult<U> Map<T, U>(this IResult<T> result, Func<T, U> selector)
-
Parameters:
-
result
: The inputIResult<T>
instance. -
selector
: A function that takes the successful value of typeT
and returns a new value of typeU
.
-
-
Returns: An
IResult<U>
instance. Ifresult
was successful, it contains the transformed value. Ifresult
was a failure, it returns the original failedresult
(asIResult<U>
). -
Example: Converting a string ID to an integer, or transforming a data entity to a DTO.
using Zentient.Results; using System; // Assume we have a method to parse an ID string public IResult<int> ParseIdString(string idText) { if (int.TryParse(idText, out int id)) { return id; // Implicitly converts to Result<int>.Success(id) } return Result.Validation<int>("InvalidIdFormat", "ID must be a number."); } // Now, use Map to fetch a user with that ID public IResult<string> GetUserNameById(string idText) { return ParseIdString(idText) .Map(userId => { // This lambda only executes if ParseIdString is successful // In a real app, this would fetch from a database Console.WriteLine($"Mapping successful ID: {userId}"); return userId == 1 ? "Alice" : (userId == 2 ? "Bob" : null); }) .Map(name => { // This second Map transforms the name (string) to a confirmation message Console.WriteLine($"Mapping successful name: {name}"); return name != null ? $"User found: {name}" : "User not found."; }); } // Usage: IResult<string> result1 = GetUserNameById("1"); Console.WriteLine(result1.IsSuccess ? result1.Value : result1.Errors.First().Message); // Output: User found: Alice IResult<string> result2 = GetUserNameById("invalid"); Console.WriteLine(result2.IsSuccess ? result2.Value : result2.Errors.First().Message); // Output: ID must be a number. IResult<string> result3 = GetUserNameById("3"); Console.WriteLine(result3.IsSuccess ? result3.Value : result3.Errors.First().Message); // Output: User not found.
Transforms Task<IResult<T>>
to Task<IResult<U>>
.
-
Signature:
public static async Task<IResult<U>> Map<T, U>(this Task<IResult<T>> resultTask, Func<T, U> selector)
-
Parameters:
-
resultTask
: ATask
that returns anIResult<T>
. -
selector
: A synchronous function to transform the successful value.
-
-
Returns: A
Task
that returns anIResult<U>
. -
Example:
using System.Threading.Tasks; public async Task<IResult<int>> GetUserIdFromApiAsync(string username) { await Task.Delay(100); // Simulate API call if (username == "admin") return 1; if (username == "guest") return 2; return Result.NotFound<int>("User", username); } public async Task<IResult<string>> GetUserEmailByUsernameAsync(string username) { return await GetUserIdFromApiAsync(username) .Map(userId => { // This selector runs synchronously after GetUserIdFromApiAsync completes successfully Console.WriteLine($"Got user ID: {userId}"); return userId == 1 ? "[email protected]" : "[email protected]"; }); } // Usage: var emailResult = await GetUserEmailByUsernameAsync("admin"); Console.WriteLine(emailResult.IsSuccess ? emailResult.Value : emailResult.Errors.First().Message); // Output: Got user ID: 1 \n [email protected]
The Bind
(often called flatMap
in other contexts) extension method is used to chain operations where each step itself returns another IResult
. This is crucial when you have a sequence of fallible operations, and if any step in the sequence fails, the entire sequence should fail.
Think of Bind
as applying a function that "flattens" nested Result
s (e.g., Result<Result<T>>
becomes Result<T>
).
Transforms IResult<T>
to IResult<U>
by applying a function that returns IResult<U>
.
-
Signature:
public static IResult<U> Bind<T, U>(this IResult<T> result, Func<T, IResult<U>> binder)
-
Parameters:
-
result
: The inputIResult<T>
. -
binder
: A function that takes the successful value of typeT
and returns a newIResult<U>
. This function is only executed ifresult
is successful.
-
-
Returns: An
IResult<U>
instance. Ifresult
was successful, it returns the result ofbinder
. Ifresult
was a failure, it propagates the original failedresult
(asIResult<U>
). -
Example: Fetching a user, then using that user to fetch their orders. Each step can fail independently.
using Zentient.Results; using System.Collections.Generic; // Simulate database operations public IResult<User> GetUserById(int userId) { if (userId == 1) return new User { Id = 1, Name = "Alice" }; return Result.NotFound<User>("User", userId.ToString()); } public IResult<List<Order>> GetOrdersForUser(User user) { if (user.Id == 1) return new List<Order> { new Order { Id = 101, UserId = 1 }, new Order { Id = 102, UserId = 1 } }; // Simulate a scenario where a user might not have orders, but it's not an error return new List<Order>(); } public IResult<List<Order>> GetUserOrders(int userId) { return GetUserById(userId) // Returns IResult<User> .Bind(user => { // This binder only runs if GetUserById is successful Console.WriteLine($"User '{user.Name}' found. Fetching orders..."); return GetOrdersForUser(user); // Returns IResult<List<Order>> }); } // Usage: IResult<List<Order>> ordersResult1 = GetUserOrders(1); Console.WriteLine(ordersResult1.IsSuccess ? $"Orders count: {ordersResult1.Value.Count}" : ordersResult1.Errors.First().Message); // Output: User 'Alice' found. Fetching orders... \n Orders count: 2 IResult<List<Order>> ordersResult2 = GetUserOrders(99); Console.WriteLine(ordersResult2.IsSuccess ? $"Orders count: {ordersResult2.Value.Count}" : ordersResult2.Errors.First().Message); // Output: Resource 'User' with identifier '99' not found.
Transforms Task<IResult<T>>
to Task<IResult<U>>
by applying an asynchronous function that returns Task<IResult<U>>
.
-
Signature:
public static async Task<IResult<U>> Bind<T, U>(this Task<IResult<T>> resultTask, Func<T, Task<IResult<U>>> binder)
-
Parameters:
-
resultTask
: ATask
that returns anIResult<T>
. -
binder
: An asynchronous function that takes the successful value of typeT
and returns aTask<IResult<U>>
. This function is only executed ifresultTask
completes successfully.
-
-
Returns: A
Task
that returns anIResult<U>
. -
Example:
using System.Threading.Tasks; public async Task<IResult<string>> FetchUserDataAsync(int userId) { await Task.Delay(100); // Simulate async operation if (userId == 10) return "User Data for 10"; return Result.NotFound<string>("User Data", userId.ToString()); } public async Task<IResult<string>> ProcessUserDataAsync(string data) { await Task.Delay(50); // Simulate async processing if (data.Contains("critical")) return Result.Conflict<string>("CriticalData", "Data contains critical keywords."); return $"Processed: {data}"; } public async Task<IResult<string>> FetchAndProcessUser(int userId) { return await FetchUserDataAsync(userId) // Returns Task<IResult<string>> .Bind(async userData => { // This binder only runs if FetchUserDataAsync is successful Console.WriteLine($"Fetched data: {userData}"); return await ProcessUserDataAsync(userData); // Returns Task<IResult<string>> }); } // Usage: var processedResult1 = await FetchAndProcessUser(10); Console.WriteLine(processedResult1.IsSuccess ? processedResult1.Value : processedResult1.Errors.First().Message); // Output: Fetched data: User Data for 10 \n Processed: User Data for 10 var processedResult2 = await FetchAndProcessUser(99); Console.WriteLine(processedResult2.IsSuccess ? processedResult2.Value : processedResult2.Errors.First().Message); // Output: Resource 'User Data' with identifier '99' not found.
The Then
extension method is similar to Bind
but is used when the subsequent operation returns a non-generic IResult
(or Task<IResult>
). It's useful for chaining operations that don't produce a value but only indicate success or failure (e.g., a save, delete, or validation step).
-
Signature:
public static IResult Then<T>(this IResult<T> result, Func<T, IResult> nextOperation)
-
Purpose: If
result
is successful, executesnextOperation
usingresult.Value
. Ifresult
is a failure, propagates the failure.
-
Purpose: If
-
Signature:
public static IResult Then(this IResult result, Func<IResult> nextOperation)
-
Purpose: If
result
is successful, executesnextOperation
. Ifresult
is a failure, propagates the failure.
-
Purpose: If
-
Example: Validate input, then save it, where saving is a non-value returning operation.
using Zentient.Results; public IResult<string> ValidateInput(string input) { if (string.IsNullOrEmpty(input)) return Result.Validation<string>("EmptyInput", "Input cannot be empty."); return input; } public IResult SaveData(string data) { if (data.Length > 100) return Result.Failure(new ErrorInfo("DataTooLarge", "Data exceeds limit.")); Console.WriteLine($"Saving data: {data}"); return Result.Success(); } public IResult ValidateAndSave(string input) { return ValidateInput(input) // Returns IResult<string> .Then(data => { // This executes if ValidateInput is successful, 'data' is the string value Console.WriteLine($"Input validated: {data}"); return SaveData(data); // Returns IResult }); } // Usage: Console.WriteLine(ValidateAndSave("short data").IsSuccess); // Output: Input validated: short data \n Saving data: short data \n True Console.WriteLine(ValidateAndSave("").IsSuccess); // Output: False (Validation failure propagated) Console.WriteLine(ValidateAndSave(new string('a', 101)).IsSuccess); // Output: Input validated: a...a \n False (SaveData failure propagated)
-
Signatures:
public static async Task<IResult> Then<T>(this Task<IResult<T>> resultTask, Func<T, Task<IResult>> nextOperation)
public static async Task<IResult> Then(this Task<IResult> resultTask, Func<Task<IResult>> nextOperation)
-
Purpose: Asynchronously chains operations that return non-generic
Task<IResult>
. -
Example:
using System.Threading.Tasks; public async Task<IResult<int>> GetUserBalanceAsync(int userId) { await Task.Delay(50); if (userId == 1) return 100; return Result.NotFound<int>("Balance", userId.ToString()); } public async Task<IResult> DeductAmountAsync(int userId, int amount) { await Task.Delay(50); Console.WriteLine($"Deducting {amount} from user {userId}"); if (userId == 1 && amount > 100) { return Result.Conflict("InsufficientFunds", "Not enough balance."); } return Result.Success(); } public async Task<IResult> ProcessPaymentFlow(int userId, int amountToPay) { return await GetUserBalanceAsync(userId) // Returns Task<IResult<int>> .Then(async balance => { // Executes if GetUserBalanceAsync is successful Console.WriteLine($"User {userId} has balance: {balance}"); return await DeductAmountAsync(userId, amountToPay); // Returns Task<IResult> }); } // Usage: Console.WriteLine((await ProcessPaymentFlow(1, 50)).IsSuccess); // Output: User 1 has balance: 100 \n Deducting 50 from user 1 \n True Console.WriteLine((await ProcessPaymentFlow(1, 150)).IsSuccess); // Output: User 1 has balance: 100 \n Deducting 150 from user 1 \n False Console.WriteLine((await ProcessPaymentFlow(99, 50)).IsSuccess); // Output: False (NotFound propagated)
The Tap
extension method is used to perform a side-effecting action on the successful value of an IResult<T>
without changing the result itself. It always returns the original Result
instance. If the result is a failure, the action is skipped, and the failure is propagated.
Think of Tap
as "inspect and pass through." It's great for logging, auditing, or debugging.
-
Signature:
public static IResult<T> Tap<T>(this IResult<T> result, Action<T> onSuccess)
-
Parameters:
-
result
: The inputIResult<T>
. -
onSuccess
: AnAction
that takes the successful value of typeT
.
-
-
Returns: The original
IResult<T>
instance. -
Example: Logging the retrieved user's name without altering the
Result
.using Zentient.Results; public IResult<User> GetUserFromDb(int userId) { if (userId == 1) return new User { Id = 1, Name = "Alice" }; return Result.NotFound<User>("User", userId.ToString()); } public IResult<User> FetchAndLogUser(int userId) { return GetUserFromDb(userId) .Tap(user => Console.WriteLine($"User '{user.Name}' fetched successfully.")); // This action runs only on success } // Usage: FetchAndLogUser(1); // Output: User 'Alice' fetched successfully. FetchAndLogUser(99); // No output from Tap, failure propagated.
The OnSuccess
methods provide a way to execute an action only if the result is successful. Like Tap
, they are used for side-effects and return the original Result
instance. OnSuccess
is often considered an alias or a more semantically clear alternative to Tap
when the intent is specifically to react to success.
-
Signatures:
public static IResult OnSuccess(this IResult result, Action action)
public static IResult<T> OnSuccess<T>(this IResult<T> result, Action<T> action)
- Asynchronous overloads also exist (e.g.,
public static Task<IResult> OnSuccess(this Task<IResult> resultTask, Action action)
)
-
Purpose: To perform actions that are conditional on a successful outcome, such as logging, sending notifications, or updating UI elements.
-
Example:
using Zentient.Results; using System.Threading.Tasks; public IResult ProcessItem(string item) { if (item == "error") return Result.Failure(new ErrorInfo("ProcessingFailed", "Could not process item.")); return Result.Success(); } public async Task<IResult<bool>> SaveItemAsync(string item) { await Task.Delay(10); if (item == "fail") return Result.Failure<bool>(new ErrorInfo("SaveFailed", "Item failed to save.")); return Result.Success(true); } // Usage: ProcessItem("data") .OnSuccess(() => Console.WriteLine("Item processed successfully (non-generic).")); // Output: Item processed successfully (non-generic). ProcessItem("error") .OnSuccess(() => Console.WriteLine("This won't print.")); // Action not executed await SaveItemAsync("success") .OnSuccess(isSaved => Console.WriteLine($"Item saved successfully: {isSaved} (generic async).")); // Output: Item saved successfully: True (generic async).
The OnFailure
methods provide a way to execute an action only if the result is a failure. They are also used for side-effects and return the original Result
instance.
-
Signatures:
public static IResult OnFailure(this IResult result, Action<IReadOnlyList<ErrorInfo>> onFailure)
public static IResult<T> OnFailure<T>(this IResult<T> result, Action<IReadOnlyList<ErrorInfo>> onFailure)
- Asynchronous overloads also exist.
-
Purpose: To perform actions specific to failure scenarios, such as logging errors, displaying error messages to the user, or triggering fallback mechanisms.
-
Example:
using Zentient.Results; public IResult Authenticate(string username, string password) { if (username != "admin" || password != "password") { return Result.Unauthorized("AuthFailed", "Invalid credentials."); } return Result.Success(); } // Usage: Authenticate("user", "123") .OnFailure(errors => { Console.WriteLine($"Authentication failed: {errors.First().Message}"); // Log full errors: Log.Error(JsonSerializer.Serialize(errors)); }); // Output: Authentication failed: Invalid credentials. Authenticate("admin", "password") .OnFailure(errors => Console.WriteLine("This won't print.")); // Action not executed
The Match
extension method is a powerful tool for exhaustively handling both the success and failure cases of a Result
type in a single, fluent expression. It forces you to define a transformation for both outcomes, ultimately returning a single, unified type U
.
-
Signature:
public static U Match<T, U>(this IResult<T> result, Func<T, U> onSuccess, Func<IReadOnlyList<ErrorInfo>, U> onFailure)
- There are also non-generic
Match
overloads forIResult
.
- There are also non-generic
-
Parameters:
-
result
: The inputIResult<T>
instance. -
onSuccess
: A function that takes the successful value of typeT
and returns a value of typeU
. This is executed ifresult.IsSuccess
istrue
. -
onFailure
: A function that takes the list ofErrorInfo
objects and returns a value of typeU
. This is executed ifresult.IsFailure
istrue
.
-
-
Returns: A value of type
U
, which is the result of applying eitheronSuccess
oronFailure
. -
Purpose: To convert a
Result
into a different representation (e.g., a UI model, a different data structure, or a status string) based on its outcome, ensuring all paths are handled. -
Example: Converting a
Result<User>
into a string message for UI display.using Zentient.Results; using System.Linq; public IResult<User> GetUserData(int userId) { if (userId == 1) return new User { Id = 1, Name = "Alice", Email = "[email protected]" }; if (userId == 0) return Result.Validation<User>("InvalidId", "User ID cannot be zero."); return Result.NotFound<User>("User", userId.ToString()); } public string GetUserStatusMessage(int userId) { return GetUserData(userId).Match( onSuccess: user => $"Successfully loaded user: {user.Name} ({user.Email})", onFailure: errors => { var firstError = errors.First(); return $"Failed to load user. Error: {firstError.Code} - {firstError.Message}"; } ); } // Usage: Console.WriteLine(GetUserStatusMessage(1)); // Output: Successfully loaded user: Alice ([email protected]) Console.WriteLine(GetUserStatusMessage(99)); // Output: Failed to load user. Error: UserNotFound - Resource 'User' with identifier '99' not found. Console.WriteLine(GetUserStatusMessage(0)); // Output: Failed to load user. Error: InvalidId - User ID cannot be zero.