zentient results api reference Functional Utilities - ulfbou/Zentient.Results GitHub Wiki

🔁 Functional Utilities


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.

Map Extension Methods

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.

Synchronous Map

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 input IResult<T> instance.
    • selector: A function that takes the successful value of type T and returns a new value of type U.
  • Returns: An IResult<U> instance. If result was successful, it contains the transformed value. If result was a failure, it returns the original failed result (as IResult<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.

Asynchronous Map

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: A Task that returns an IResult<T>.
    • selector: A synchronous function to transform the successful value.
  • Returns: A Task that returns an IResult<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]

Bind Extension Methods

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 Results (e.g., Result<Result<T>> becomes Result<T>).

Synchronous Bind

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 input IResult<T>.
    • binder: A function that takes the successful value of type T and returns a new IResult<U>. This function is only executed if result is successful.
  • Returns: An IResult<U> instance. If result was successful, it returns the result of binder. If result was a failure, it propagates the original failed result (as IResult<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.

Asynchronous Bind

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: A Task that returns an IResult<T>.
    • binder: An asynchronous function that takes the successful value of type T and returns a Task<IResult<U>>. This function is only executed if resultTask completes successfully.
  • Returns: A Task that returns an IResult<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.

Then Extension Methods

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).

Synchronous Then (from IResult<T> or IResult)

  • Signature: public static IResult Then<T>(this IResult<T> result, Func<T, IResult> nextOperation)

    • Purpose: If result is successful, executes nextOperation using result.Value. If result is a failure, propagates the failure.
  • Signature: public static IResult Then(this IResult result, Func<IResult> nextOperation)

    • Purpose: If result is successful, executes nextOperation. If result is a failure, propagates the failure.
  • 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)

Asynchronous Then (from Task<IResult<T>> or Task<IResult>)

  • 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)

Tap Extension Method

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 input IResult<T>.
    • onSuccess: An Action that takes the successful value of type T.
  • 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.

OnSuccess Extension Methods

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).

OnFailure Extension Methods

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

Match Extension Method

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 for IResult.
  • Parameters:

    • result: The input IResult<T> instance.
    • onSuccess: A function that takes the successful value of type T and returns a value of type U. This is executed if result.IsSuccess is true.
    • onFailure: A function that takes the list of ErrorInfo objects and returns a value of type U. This is executed if result.IsFailure is true.
  • Returns: A value of type U, which is the result of applying either onSuccess or onFailure.

  • 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.
⚠️ **GitHub.com Fallback** ⚠️