Handling Errors Without Exceptions: Part 1
Exceptions are the de facto way to handle errors in C#, but they have problems. Callers don’t know if the function they’re calling will throw an exception at all or which types of exceptions it’ll throw. Exceptions also introduce an alternative control flow that’s often hard for programmers to follow. They make our code slower too, even when never thrown! Today’s article introduces an alternative to exceptions to help solve all of these issues. Read on to learn a new way to handle errors!
Back in the (very) old days there were no exceptions at all and errors were handled in one of two primitive ways. The first is to return an error code of some sort to indicate that there was a problem:
string ValidatePassword(string password) { if (string.IsNullOrEmpty(password)) { return null; // <-- error code } if (password.All(char.IsLetter)) { return null; // <-- error code } return password.Trim(); }
Then we hope that the caller handles the error code:
void Foo() { var password = ValidatePassword("abc"); Debug.Log("password length: " + password.Length); // <-- null exception, does not print }
Part of the problem here is that the caller has no idea that they should expect an error code. Sure, it’s obvious in this case because we all know that a validate function can fail. It won’t be nearly as obvious in more complicated code and errors will go unhandled.
The other old approach was to use an error flag rather than an error code:
class Database { enum Error { Empty, OnlyLetters } Error ValidationError { get; private set; } // <-- error flag void ValidatePassword(string password) { if (string.IsNullOrEmpty(password)) { ValidationError = Error.Empty; // <-- set error flag } if (password.All(char.IsLetter)) { ValidationError = Error.OnlyLetters; // <-- set error flag } } }
Again we expect the caller to check this error flag, but it’s even less likely since they really have to go out of their way to explicitly check the error flag.
Exceptions are just as easy for a programmer to miss. All you have to do is not write a try-catch
block and the exception will go unhandled. How do you know that you should have written a try-catch
block? There’s nothing in the function signature to tell you:
string ValidatePassword(string password) // <-- doesn't say this function throws an exception { if (string.IsNullOrEmpty(password)) { throw new Exception("empty"); // <-- throw exception } if (password.All(char.IsLetter)) { throw new Exception("all letters"); // <-- throw exception } return password.Trim(); }
The difference with exceptions is that they cause catastrophic damage to the program if they’re not handled. Usually the whole program quits or, in Unity, at least skips the rest of the code for that frame. The intention is that this will get your attention as a programmer and you’ll go solve the problem. But what if the exception is never thrown during your development process and only starts happening once your end users get ahold of the app? Suddenly there are huge problems due to uncaught exceptions and you’re writing emergency patches to the code.
The fundamental issue with all three of these approaches is that none of them clearly indicate what happens when there is an error. This presents an opportunity for improvement, which I’ll now talk about. It’s not a new idea and is actually the primary way of handling errors in languages like F# (see this excellent guide). The basic idea is to wrap both the success and failure results into a single object so the return type clearly specifies both.
To implement this we need a type that can be either one thing or another but not both. Luckily, I just wrote this article about how to add unions to C#. Here’s a generic version that works for any two types:
// http://jacksondunstan.com/articles/3349 [StructLayout(LayoutKind.Explicit)] public struct Union<TLeft, TRight> { [FieldOffset(0)] public TLeft Left; [FieldOffset(0)] public TRight Right; }
We could use this with ValidatePassword
to indicate both the success and the failure cases to the caller:
enum Error { Empty, OnlyLetters } Union<string, Error> ValidatePassword(string password)
But how would the caller handle a Union
return value? They wouldn’t know if it was the left/success or right/failure case. So we need to wrap the union in another type that specifies whether it’s a success or a failure. Let’s call that type Either
:
// http://jacksondunstan.com/articles/3349 public struct Either<TLeft, TRight> { private bool isLeft; private Union<TLeft, TRight> union; public Either(TLeft left) { isLeft = true; union.Right = default(TRight); union.Left = left; } public Either(TRight right) { isLeft = false; union.Left = default(TLeft); union.Right = right; } public TLeft Left { get { if (isLeft == false) { throw new Exception("Either doesn't hold Left"); } return union.Left; } set { union.Left = value; isLeft = true; } } public TRight Right { get { if (isLeft) { throw new Exception("Either doesn't hold Right"); } return union.Right; } set { union.Right = value; isLeft = false; } } public bool IsLeft { get { return isLeft; } } }
Now we can rewrite ValidatePassword
like so:
Either<string, Error> ValidatePassword(string password) { if (string.IsNullOrEmpty(password)) { return new Either<string, Error>(Error.Empty); } if (password.All(char.IsLetter)) { return new Either<string, Error>(Error.OnlyLetters); } return new Either<string, Error>(password.Trim()); }
And the caller can handle it like this:
void Foo() { var result = ValidatePassword("abc"); if (result.IsLeft) { Debug.Log("your password: " + result.Left); } else { Debug.Log("invalid password: " + result.Right); } }
The caller is now explicitly presented with the failure case so they know that they should handle it. If there is an error and they try to use the success value, Either
falls back to throwing an exception. ValidatePassword
is now hard to use wrong, but we can make it easier to use right with a little extension function:
// http://jacksondunstan.com/articles/3349 public static class EitherExtensions { public static TResult Match<TLeft, TRight, TResult>( this Either<TLeft, TRight> either, Func<TLeft, TResult> leftMatcher, Func<TRight, TResult> rightMatcher ) { if (either.IsLeft) { return leftMatcher(either.Left); } else { return rightMatcher(either.Right); } } }
Now the caller looks like this:
void Foo() { var result = ValidatePassword("abc"); result.Match( password => Debug.Log("your password: " + password), error => Debug.Log("invalid password: " + error) ); }
Those are the basics of this technique. Using Either
makes it easy for function writers to declare to function callers what kinds of errors can happen. Match
makes it easy to handle both the success and failure cases. There’s no alternative control flow, so it’s easy to follow what’s happening without needing to imagine a call stack and where the throw
statements and catch
blocks are. And no exceptions are ever actually thrown unless you ignore IsLeft
and try to access the wrong value. So there’s none of the performance overhead you get with throw
and catch
.
That’s it for today. In next week’s follow-up article I’ll discuss how you can go further with Either
to extend the error handling process to a more general technique. In the meantime, let me know what you think of Either
in the comments!
Update: Check out this comment for another interesting approach to declarative error handling using out
parameters.
#1 by Thomas Viktil on February 1st, 2016 ·
I think you could have solved this issue easier, with a lot less code, and get an even more explicit API using the out keyword.
By declaring the ValidatePassword method like this:
public bool ValidatePassword(string pwd, out Response response) {
// validation
response.value = ResponseType.success;
return true;
}
The coder wouldn’t be allowed to call the method without instantiating a Response object, which would also serve as a reminder to handle the feedback.
#2 by jackson on February 1st, 2016 ·
That’s a really interesting suggestion because
out
parameters do allow the function to indicate more than one response, such as the success and error responses in this case. I think this version of the function would be closer though:I changed
ValidatePassword
so that it can return astring
by return andError
byout
parameter. This implies thatValidatePassword
needs to always set the error parameter, so a “no error” state needed to be added to theError
enum. It also means writing two lines of code for each case. One sets the error and one returns the success. In the case of success, an error code has to be returned so I usednull
.An alternative to this would be to have the function return
void
and take twoout
parameters. That’s more or less the same, but handling errors involves three statements to set theout
parameters and return from the function. I’m not entirely sure which is the better option.On the caller side,
Foo
now needs to declare anError
local variable and pass it in as anout
parameter. It needs to either check that the error is the “no error” state or check that the return value is the error code (null
in this case).I think this approach is better than the “error code”, “error flag”, or exception approaches because, as you point out, it does remind the caller to handle the error. The caller has to ignore the error output that they themselves declared. That’s a big benefit, but I think the
Either
approach is even better. You can’t accidentally use the wrong return value withEither
because an exception would be thrown. You don’t have to declare an error local variable and pass it in. The callee doesn’t need to explicitly set two values in order to return. There’s a minor space savings toEither
, too, since it uses a union to store only the success or failure result. Also, it’s possible to write a function likeMatch
(and all the additional functions you’ll see in next week’s article) withEither
, which makes handling the success and failure cases the easiest out of all approaches (in my opinion).Thanks for pointing out the
out
parameter approach; I totally missed it in the article. I think it’s a solid #2 option next toEither
. I’ve updated the article to link to your comment (and my response) so more readers take note.