Implementing Error Handling that Hopefully Doesn't Suck
Hakanai
Posted on September 13, 2022
Where there is software, there are errors. (Here, we treat errors and exceptions as synonyms, because languages do not agree on the terminology in the first place.)
Some errors are expected. Some are unexpected.
But for whatever the reason, errors can occur, and whether you're writing an application or a library, you should think about your strategy for dealing with errors.
This article goes over the commonly used error handling strategies, and offers advice on how to use each.
Strategy 1: Error return codes
A very common error handling strategy in structural programming is simply to return an error code from all functions.
There are three common sub-approaches here:
- Return 0 for success, anything else for failure
- Return boolean true for success, false for failure
- Return the actual function result for success, a sentinel value for failure
An example of returning 0 for success is POSIX's fsetpos
:
int fsetpos(FILE *stream, const fpos_t *pos);
An example of returning true for success is Windows' CreateDirectoryW
:
BOOL CreateDirectoryW(
[in] LPCWSTR lpPathName,
[in, optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes
);
An example of returning a sentinel value is Windows' CreateFileW
:
HANDLE CreateFileW(
[in] LPCWSTR lpFileName,
[in] DWORD dwDesiredAccess,
[in] DWORD dwShareMode,
[in, optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes,
[in] DWORD dwCreationDisposition,
[in] DWORD dwFlagsAndAttributes,
[in, optional] HANDLE hTemplateFile
);
If this function fails, it returns INVALID_HANDLE_VALUE
, which happens to be -1. Valid handles cannot be negative, so this will never clash. You should only use a sentinel value in this situation where the legal return values do not take up the entire value space.
Since returning a value is not particularly informative if there are multiple reasons an error could have occurred, this sort of API usually comes with an additional function you can call in order to get further details about the error. For this, POSIX provides the strerror
function, while Windows provides the GetLastError
function.
Advice for using this style of error handling:
- Don't use it if you're writing in an object-oriented or functional language - users of these languages would generally consider this to be bad form.
- Try to use consistent style for handling errors in all functions. Don't follow the POSIX or Windows examples of choosing this on a function-by-function basis.
- Try to separate your errors into different error return codes so that people have an idea what kind of error happened just from seeing the error code in a log. Some callers will crash on any non-successful return code and not bother to call your separate method to get the explanation.
- If you have to choose, go with 0 for success and other values for error codes, because it gives you the largest possible chance of keeping the API consistent while allowing the maximum number of unique return values for errors.
- Provide a function to get the error message as a string. Allow passing the user's locale into that function, so they can get the message in their language, not yours.
Strategy 2: Throwing
Many languages implement a style of exception handling where an exception can be thrown (or raised) from any stack frame, which can then be caught any number of stack levels away by some other code which signed up to catch it.
Languages like Java separate exceptions into checked exceptions and unchecked exceptions. The fundamental principle here is that you use checked exceptions for conditions where the caller really should be aware of the possibility of the error, while you use unchecked exceptions where the exception is essentially unexpected.
Unfortunately, Java's standard library does an extremely bad job of using the right kind of exception for the right situation:
-
Unexpected errors reading from a file or the network typically use checked IOException when it should have used unchecked - things like
FileNotFoundException
make sense being checked exceptions, but general I/O errors because a disk was yanked from the system do not. -
Expected errors parsing a value will typically use an unchecked
NumberFormatException
,IllegalArgumentException
or similar.
Because Java's standard library has done such a bad job of using this feature, some people consider checked exceptions a bad idea in general. But this is unfair on the feature—in general, checked exceptions are a good idea when used correctly.
If you are writing a library using this sort of exception to relay errors, here is my advice:
- Only put checked exceptions on API methods where the caller really has to pay attention to the error condition at the site they call it, and use them extremely sparingly in general. Consider: if the caller catches this exception, is there realistically anything they can do to recover from it? (For example, if they get a checked
FileNotFoundException
, they might want to create the file. But if they get a checkedNetworkOfflineException
, they're going to be annoyed because there is nothing they can do to bring back the network.) - Use polymorphism. Declare a common base class for your exceptions, but then declare one subclass for each reason they could occur. The caller does not want to have to parse your English exception messages to figure out whether they got an
AuthenticationException
or aConnectionException
. - Group together exceptions which have the same kind of underlying cause, e.g., all authentication exceptions. The caller often wants to display a message for a given category and doesn't care about which of a dozen potential underlying causes were the reason.
- If you're carrying more than a hard-coded message in your exceptions, add methods or properties allowing the caller to get the useful information out of the exception. Again, the caller does not want to parse your messages to get this out.
Strategy 3: Polymorphic return values
If you're working in a language where things are type-safe, and there is no sensible way to return multiple potential types from a single method, one of your options is to specify the method as returning a base class and then have multiple potential implementations of that class.
For example, you might have the method return a GetUserResponse
, and then actually implement GetUserSuccess
and GetUserFailure
subclasses for that response type.
This is somewhat cleaner in languages such as Kotlin, where you can declare the base class as sealed
, as the caller can then see the complete list of possible subclasses they have to handle. But there is no way this can be bulletproof if it appears in a public API - a sealed class today could have one more subclass tomorrow, so it gives the caller a false sense of security about the API stability.
Because new subclasses can appear at any moment, I'm not a big fan of this style of exception handling in general, but it can make a lot of sense for simplifying internal application code.
Advice for this type of exception handling:
- If you're using sealed classes, limit that to internal logic in your application and don't expose it to a caller - the reasoning is that the caller's safety is ripped out from under them if you ever add an additional subclass.
- Consider how you will distinguish different possible failures - will you have different subclasses, or just a detailed failure property inside the returned Failed object?
Strategy 4: Try
monads
As an alternative to using polymorphism to indicate the outcome, another approach is to use monads, which I will loosely equate to wrapper classes for people who don't have a functional background.
The general idea here is that you declare a Try<T>
class which gets used like this:
fun doSomething(): Try<DoSomethingResult> {
try {
// do the thing here
return Try.of(successfulResult)
} catch (e: SomeException) {
return Try.ofFailure(e)
}
}
This comes out very similar to how you would use an Optional<T>
instead of returning nullable values - you return a Try<R>
instead of throwing the exception.
The caller then is forced to handle the exception—they have to unpack the Try wrapper to get the actual result out—so you can add convenience methods like ifSucceeded
/ifFailed
, methods like map
which can transform the success condition, methods like flatMap
which can be used to chain subsequent calls while collecting the first error which occurred, and so forth. There are almost certainly even some libraries you can use which provide this utility class out of the box.
When using this pattern, I have noticed there seems to be a temptation to avoid using actual exception classes for the failure object, but if you're working inside a language where thrown exceptions are a feature, I recommend still using the provided class hierarchy which is otherwise used for thrown exceptions. My reasoning:
- The caller might want to rethrow it as an actual exception, even if you didn't want to
- The caller might want to attach your failure object as the cause for their own exception
- The caller might want to log a stack trace of the failure—by returning a simpler object, you throw away all the useful diagnostics about where the error occurred
Other than that, this is a fairly straightforward pattern to use, and people familiar with Optional<T>
will already get the gist of how to use Try<R>
.
Advice for this one:
- Only use it in those cases where the error must be handled by the caller. Because it essentially makes the exception a checked exception, you want to try to avoid forcing people to handle every possible kind of error. This pattern is a bad fit for unexpected errors because it increases the burden on the caller using the API.
- When possible, use real exception objects to represent the error conditions, so that the caller can treat them the usual way if they want.
Tying things up
The tl;dr comes down to:
- If you're using a functional style, go with
Try<T>
monads. - If you're using an object-oriented style, choose between
Try<T>
monads or thrown exceptions and stick to it consistently, and if you do use thrown exceptions, beware of which ones you make checked exceptions. - If you have no better option, return 0 for success and a unique error code for failure, and provide a function to explain the error code.
- Regardless of which you choose, consider what the caller is going to want to do when they get the error.
Posted on September 13, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.