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.
ExceptionDispatchInfo
\$\endgroup\$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\$