3
\$\begingroup\$

I recently came across the OneOf<T0,...,Tn> package from mcintyre321 via a video by Nick Chapsas, which presents the idea of holding exception types to be returned in the same object as would hold a successful result; e.g.

public OneOf<User, InvalidEmail, EmailAlreadyExists> CreateUser { /* ... */ }

This made me consider; aside from the expected return type (User), all other types are to cater for exceptions.
Also the caller of the class library (not API) who may only be interested in receiving a valid user or having an exception thrown, now needs to implement that logic for themselves, converting the OneOf approach to their own needs.

I considered the below class; as a way to also allow an expected result class or an Exception to be returned (not thrown).

public class ResultOrException<T> 
{
    
    private T _result;
    public bool IsException {get {return Exception != null;}}
    public Exception Exception {get;private set;}
    
    public ResultOrException(): this(new InvalidOperationException($"A {nameof( ResultOrException<T> )} class has been accessed before it is assigned to.")){}

    public ResultOrException(T result)
    {
        _result = result;
    }

    public ResultOrException(Exception exception)
    {
        Exception = exception ?? throw new ArgumentNullException(nameof(exception));
    }
    
    public static implicit operator T(ResultOrException<T> value) 
    {
        if (value.IsException)
        {
            throw value.Exception;
        }
        return value._result;
    }
    
    public static implicit operator ResultOrException<T>(T result) 
    {
        return new ResultOrException<T>(result);
    }
}

This includes implicit conversion to the expected result type; so if the client's expecting a value of type User they don't need to do any heavy lifting to get that; they just rely on implicit conversion. If the returned value was an exception, the exception gets thrown during the conversion so the information in that exception is then accessible to the caller.

However if the caller wants to handle the different exception cases differently (e.g. to return the exception to their API's caller), they can implement their own logic to handle that, without having to catch the thrown exception as they may if working with a more traditional class library.

Here's a simple example of the above class being used in practice:

void Main()
{
    var userManager = new UserManager();
    var found = false;
    var result = new ResultOrException<User>(); 
    while (!found) {
        Console.WriteLine("Please enter your username");
        var id = Console.ReadLine();
        result = userManager.GetById(id);
        found = !result.IsException;
        if (!found)
        {
            Console.WriteLine("{0}.  Please try again.", result.Exception?.Message);
        }
    }
    User loggedInAs = result;
    Console.WriteLine($"You've successfully logged in as '{loggedInAs.Name}'");
    User nonExistant = userManager.GetById("[email protected]");
    Console.WriteLine("We'll never get here; as the above line throws an exception");
}

public class UserManager
{
    IDictionary<string, User> db;
    public UserManager()
    {
        var users = new []{
            new User("[email protected]", "Anne"),
            new User("[email protected]", "Robert")
        };
        db = new Dictionary<string, User>();
        foreach (var user in users)
        {
            db.Add(user.Id, user);
        }
    }

    public ResultOrException<User> GetById (string id)
    {
        if (db.ContainsKey(id))
        {
            return db[id];
        }
        return new ResultOrException<User>(new UserNotFoundException(id));
    }
}

public class User
{
    public string Id {get; private set;}
    public string Name {get; private set;}
    public User (string id, string name)
    {
        Id = id;
        Name = name;
    }
}

public class NotFoundException: Exception
{
    public NotFoundException(string message): base(message){}
}
public class UserNotFoundException: NotFoundException
{
    public UserNotFoundException(string userId): base($"No user with id '{userId}' was not found"){}
}

One issue with this approach is that the stack trace shows the error coming from the implicit conversion, rather than from where it's thrown; but I feel like in this context that shouldn't be an issue; as we'd only use this approach for functional exceptions. I.e. I wouldn't propose catching any exception and bundling them into the returned ResultOrException result; only assigning those exceptions which we'd expect a user to handle as part of the normal flow of business logic.

I'd be interested in others' thoughts on this approach.

\$\endgroup\$
9
  • 5
    \$\begingroup\$ If StackTrace matters for you, then I would suggest to take a look at the ExceptionDispatchInfo \$\endgroup\$ Oct 18, 2021 at 9:44
  • 1
    \$\begingroup\$ Thanks @PeterCsala; I wasn't aware of that class; definitely useful in some scenarios. For this use case I believe the exception's stack trace probably isn't needed; since the thrown exceptions are due to specific business logic scenarios; but that's a great tool to have in my toolkit. \$\endgroup\$
    – JohnLBevan
    Oct 18, 2021 at 9:47
  • 1
    \$\begingroup\$ I think you might misunderstand the intent of the OneOf library. Instead of defining exceptions for not exceptional (rather normal) cases (like user not found) you define different state/result objects. With this you make them as explicit as possible to the caller that they should prepare for that case as well. \$\endgroup\$ Oct 18, 2021 at 9:48
  • 1
    \$\begingroup\$ This mapping is usually implemented in an exception handler middleware. Which means your controller's actions can throw exceptions as well. \$\endgroup\$ Oct 18, 2021 at 10:01
  • 1
    \$\begingroup\$ My reason for throwing an exception from the implicit conversion was for scenarios where the caller hadn't checked for IsException. Typically I'm thinking if my same class library is used elsewhere, where the client expects the class to throw exceptions or return objects of the requested type; i.e. so this more traditional flow is also supported by the same class library, without the client having to implement If (result.IsException) {throw result.Exception;} DoSomething(result.Result); everywhere. I've demoed both use cases in the sample code; one checking the exception, the other throwing \$\endgroup\$
    – JohnLBevan
    Oct 18, 2021 at 10:02

1 Answer 1

5
\$\begingroup\$

Other solutions

There have already been several attempts to create something similar. They were called: Maybe, Failable, Either etc. Here are some examples and you'd find many more:


Own experience

I like experimenting so I was curious how such a Maybe would work in a real project. At the end it turned out create more problems than it solved. In order for this to work reliably and consistently every single function had to implement it. This was the only way to entirely eliminate any exceptions. There were countless extra ifs to check for failed calls and extra returns on all levels where otherwise a single exception somewhere down deep would be enough to break the execution for an entire call chain and cascade to the top level where you'd handle it.

Review

If, however, your goal is not to eliminate exceptions altogether rather than throwing them while accessing the result anyway (like in your solution) then why not just throw them where they occur? This way there is less confusion about the result and you don't wonder why it worked but actually not.

In case you want to suppress exceptions then I find the bool TrySomething(...) pattern more intuitive. Everything else should be allowed and expected to fail. Otherwise you can never really tell when something might go sideways and where you need a try/catch.

Summarizing: I advise sticking to regular exceptions and use TryX for calls that are guaranteed not to fail.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.