Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

"Add utility method to combine multiple ErrorOr instances" #120

Open
alnaimi-github opened this issue Oct 4, 2024 · 10 comments
Open

"Add utility method to combine multiple ErrorOr instances" #120

alnaimi-github opened this issue Oct 4, 2024 · 10 comments

Comments

@alnaimi-github
Copy link

Feature Request

Summary:
Add a utility method to combine multiple ErrorOr instances, collecting all errors into a single ErrorOr if any operation fails.

Details:

using System.Collections.Generic;

namespace ErrorOr
{
    public static class ErrorOrAggregationExtensions
    {
        public static ErrorOr<TValue> Combine<TValue>(params ErrorOr<TValue>[] errorOrs)
        {
            List<Error> allErrors = new();
            foreach (var errorOr in errorOrs)
            {
                if (errorOr.IsError)
                {
                    allErrors.AddRange(errorOr.Errors);
                }
            }

            return allErrors.Count > 0 ? ErrorOr<TValue>.From(allErrors) : errorOrs[0];
        }
    }
}

Use Case:
This method will allow users to easily aggregate errors from multiple operations into a single ErrorOr instance, simplifying error handling.

Example:

var success1 = new ErrorOr<int>(1);
var success2 = new ErrorOr<int>(2);
var failure = ErrorOr<int>.From(new List<Error> { Error.Validation("ValidationError", "Validation failed") });

var result = ErrorOrAggregationExtensions.Combine(success1, success2, failure);

if (result.IsError)
{
    foreach (var error in result.Errors)
    {
        Console.WriteLine(error.Description);
    }
}
else
{
    Console.WriteLine(result.Value);
}
@davidferneding
Copy link

davidferneding commented Nov 14, 2024

I fell like an extension method would help to communicate which value will be used if no ErrorOr-object has any errors:

public static ErrorOr<TValue> AppendErrors<TValue>(this ErrorOr<TValue> errorOr, params ErrorOr<object>[] errors)
{
    List<Error> combinedErrors = errorOr.ErrorsOrEmptyList;
    foreach (ErrorOr<object> error in errors)
    {
        combinedErrors.AddRange(error.ErrorsOrEmptyList);
    }

    return combinedErrors.Count == 0 ? errorOr : combinedErrors;
}
var success1 = new ErrorOr<int>(1);
var success2 = new ErrorOr<int>(2);
var failure = ErrorOr<int>.From(new List<Error> { Error.Validation("ValidationError", "Validation failed") });

var result1 = success1.AppendErrors(success2);
var result2 = success1.AppendErrors(success2, failure);

result1.ThenDo(x => Console.WriteLine(x)).ElseDo(x => { foreach (var error in x) Console.WriteLine(error.Description); }); // writes 1
result2.ThenDo(x => Console.WriteLine(x)).ElseDo(x => { foreach (var error in x) Console.WriteLine(error.Description); }); // writes Validation failed

What do you think? If that looks good to you, I'll open a PR

@alnaimi-github
Copy link
Author

alnaimi-github commented Nov 15, 2024

The idea is really good, and the changes you've made are clear. However, I noticed that you're using ErrorOr<object> in the parameters of the AppendErrors method. It would be better to use ErrorOr<TValue> instead of ErrorOr<object>. The reason is that the method is originally designed to work with the TValue type, and using ErrorOr<object> could introduce unnecessary complications.

Here’s how you can modify the method to work with the correct type:

public static ErrorOr<TValue> AppendErrors<TValue>(this ErrorOr<TValue> errorOr, params ErrorOr<TValue>[] errors)
{
    List<Error> combinedErrors = errorOr.ErrorsOrEmptyList;
    foreach (ErrorOr<TValue> error in errors)
    {
        combinedErrors.AddRange(error.ErrorsOrEmptyList);
    }

    return combinedErrors.Count == 0 ? errorOr : combinedErrors;
}

If these changes look good to you, you can open a PR, and I'll review it. Thanks for your work!

@davidferneding
Copy link

I agree that ErrorOr<object>[] is not optimal. The reason I chose that instead of ErrorOr<TValue>[] was to allow combining ErrorOr instances of multiple types. Since on success, only the first instance is returned, the types of the other instances don't necessarily have to match. In most scenarios I can think of, the types will not be the same for all instances. E.g.:

public ErrorOr<User> AuthorizeAsAdmin() 
{
    ErrorOr<User> user = GetUser();
    ErrorOr<Success> hasPermission = CheckPermissions(user, "admin");
    ErrorOr<int> calcResult = (1 + 1).ToErrorOr();
    return user.AppendErrors(hasPermission, calcResult);
}

IErrorOr[] should probably be correct here, simply ignoring the type for everything but the first instance.

public static ErrorOr<TValue> AppendErrors<TValue>(this ErrorOr<TValue> errorOr, params IErrorOr[] errors)
{
    ...
}

@alnaimi-github
Copy link
Author

Thanks for the explanation! I see your point about wanting to combine different types of ErrorOr instances into one call. You're right that in many cases, the types of the instances being combined won't match, and using IErrorOr[] allows for more flexibility in handling this scenario.

The idea of using IErrorOr[] makes sense because it provides a way to handle multiple different types without being restricted to a single type parameter. This would allow you to append errors across different types like ErrorOr, ErrorOr, and ErrorOr in the same operation.

However, one consideration I have is that using IErrorOr[] would mean we lose the type safety that comes with generics, since we would be dealing with a non-generic interface. This could potentially introduce runtime issues if someone were to accidentally mix incompatible types, though I understand that this is less of an issue when you only care about the errors and not the values.

I think this approach could be quite useful in many scenarios, but I'd also like to make sure that we maintain type safety where possible, especially if this becomes a commonly used extension method.

Would you be open to perhaps adding an overload for the AppendErrors method that allows both ErrorOr[] and IErrorOr[]? This way, we can have both flexible multi-type support and still maintain the option for type-safe handling when needed.

Thanks again for the suggestion!

@davidferneding
Copy link

Sure, I can add both versions, great suggestion 👍

davidferneding added a commit to davidferneding/error-or that referenced this issue Nov 15, 2024
wip, this is only the basic implementation.
ToDo:
- AppendErrors with ErrorOrs of same type using generics
- AppendErrors to `Task<ErrorOr>`
- Tests :)

Refs: amantinband#120
@zbyszekprasak
Copy link

Combining multiple results would be very nice feature, but perhaps maybe there is more general approach possible.

I am afraid that use cases presented above - where only first result type matters - might rarely be the case in real world. I can imagine a lot of use cases when you have multiple results with different types and want to combine them to give either comosite object or list of all errors encountered so far. Is it doable with AggregateErrors somehow?

See classic example above:

record Person(string FirstName, string LastName, DateOnly DateOfBirth);

// Inputs from user - potentially invalid
ErrorOr<string> firstName = "John";
ErrorOr<string> lastName = "Doe";
ErrorOr<DateOnly> dateOfBirth = DateOnly.Parse("1990-03-19");

// Domain object - must be either valid or list of errors
ErrorOrFactory.Combine(firstName, lastName, dateOfBirth)
    .Then(t =>
    {
        // Tuple deconstruction into lambda args currently is not seem to be supported at the moment.
        var (firstName, lastName, dateOfBirth) = t;
        return new Person(firstName, lastName, dateOfBirth);
    })
    .Switch(
        person => WritePerson(Console.Out, person),
        errors => WriteErrors(Console.Error, errors));

In order to achieve above effect one can add bunch of methods like this:

public static ErrorOr<(T1, T2, T3)> Combine<T1, T2, T3>(ErrorOr<T1> v1, ErrorOr<T2> v2, ErrorOr<T3> v3)
{
    IErrorOr[] args = [v1, v2, v3];
    List<Error> errors = [];

    foreach (var arg in args)
    {
        if (arg.IsError)
        {
            errors.AddRange(arg.Errors!);
        }
    }

    return errors.Count > 0 ? errors : (v1.Value, v2.Value, v3.Value);
}

Downside of this solution is that only limited amout of such methods can be added i.e. up to 8-tuples.

@amantinband It would be nice to get some design guidelines and decisions in this issue before merging #125. Is there more elegant and general solution for this?

@davidferneding
Copy link

I agree, that would be a nice feature. The eight tuple restriction should probably not be an issue in most scenarios - I can't think of a situation where I had to construct a result object from more than eight different sources without questionable design decisions.

I think the previous proposal is still valid though. In most projects I've worked on, we had command handlers that call utility methods returning ErrorOr<Success>. With AppendErrors / #125, we'd be able to skip the explicit IsError-Checks for those return values if an early return is not necessary.

Some input from @amantinband would be great, yes. I don't think there is a rush to get this feature merged quickly.

@jeffw-wherethebitsroam
Copy link

Am am facing a similar issue:

public ErrorOr<int> ParseInt(string s) { ... }

var values = new[] {"1", "2", "x"};
var result = values.Select(ParseInt);

The problem being the result is now IEnumerable<ErrorOr<int>> and I would like ErrorOr<IEnumerable<int>>.

I have this extension method:

    public static ErrorOr<List<T>> Collect<T>(this IEnumerable<ErrorOr<T>> errorOrs)
    {
        var errors = new List<Error>();
        var values = new List<T>();

        foreach (var errorOr in errorOrs)
        {
            if (errorOr.IsError)
            {
                errors.AddRange(errorOr.Errors);
            }
            else
            {
                values.Add(errorOr.Value);
            }
        }

        return errors.Count > 0 ? errors : values;
    }

Which lets you do:

var values = new[] {"1", "2", "x"};
var result = values.Select(ParseInt).Collect();

Which now gives result as a ErrorOr<List<int>> (types could be improved).

This doesn't help the "combine multiple type into a single ErrorOr" problem (I think that tuples are the only way there), but does solve the original issue.

@zbyszekprasak
Copy link

Yes, now I see that those solutions are complementary rather than mutually exclusive. I like your version with ErrorOr<List<TValue>>. Thank you for sharing this example.

@alnaimi-github
Copy link
Author

That's so good

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants