Error Handling part 3: Techniques

In previous post, we explored levels of abstractions and the flow of errors through interfaces. We did not, however, examine the actual tools used to implement them.

In this post, we will review the main error handling paradigms.

Error handling techniques

Error codes

The oldest way to report errors: return a value that tells whether an error occured.

void consumer()
{
   if (provider() != Code::Ok) {
       // handle error
   }
   // continue
}
Checking for error with a code.
[[nodiscard]] Code provider()
{
   if (failure) {
       return Code::Failure;
   }
   return Code::Ok;
}
Signalling an error with a code.

Easy to implement, easy to understand, and very portable. This is common in C-style and cross-language APIs.

It is fully manual though, which implies significant drawbacks:

  • In most languages, it is buggy by default: the consumer() is expected to check for error codes, but if it forgets to do it, the error is silently discarded.

    This specific example code uses a C++17 feature, the [[nodiscard]] attribute, to ask the compiler to verify that the consumer uses the error code, alleviating that risk. This feature does not exist in most languages, though.

  • Error forwarding adds a lot of boilerplate, as every single function along the call stack must implement it manually.

    And again, if a single function misses it, it is silently discarded.

Sentinel values

Somewhat similar to error codes. The idea is to package in one type both information about success or failure and the actual result.

void consumer()
{
   auto handle = provider();
   if (handle == InvalidHandle) {
       // handle error
   }
   // use handle
}
Checking for error with a sentinel.
Handle provider()
{
   if (failure) {
       return InvalidHandle;
   }
   // continue
   return actualHandle;
}
Signalling an error with a sentienl.

Many variants exist, such as:

  • NULL pointers in C.
  • returning a negative value in many POSIX apis, such as open().
  • similarly, INVALID_HANDLE_VALUE in Win32 APIs.

The main point is to allow using the same approach as error codes, without having to use a separate parameter. It only works if the type inherently has some unused values that can be used to signify the lack of a useful value. That special value is called a sentinel.

The method has the same pros and cons as error codes.

Exceptions

Error handling as part of language control flow.

void consumer()
{
   try {
       provider();
   } catch (const Error & err) {
       // handle error
   }
   // continue
}
Checking for error with exceptions.
void provider()
{
   if (failure) {
       throw Error("blah");
   }
   // continue
}
Signalling an error with exceptions.

The main advantages of exceptions are that:

  • They make it easy to have structured error handling, typically by using some kind of exception class hierarchies.
  • They skip forwarding boilerplate entirely, as exceptions automatically raise up to the point catching them.
  • This makes them safe by default: errors will not be discarded.

This typically comes at the price of much more complexity:

  • Reasoning about code paths becomes hard: what can throw? At which point? What are the exact guarantees if a statement throws half-way through?
  • Exceptional codepaths tend to be slower on performance-oriented languages. Though irrelevant for most code as few programs really care for a few nanoseconds on an error path, it disqualifies them for time-critical applications.

Callbacks

An error handling method is defined upfront, and error conditions cause it to be called. This is used either:

  • for asynchronous error handling, where the function call that triggered the failing operation has already returned by the time the error is detected.
  • to provide additional context before returning an error code.

As it is somewhat unusual, we will not cover it.

Some design considerations

Ecosystem practices

Some languages and platforms favor one mechanism over others. For instance:

  • C does not have support for exceptions.
  • Python language and community favor exceptions.
  • Some microcontrollers make exception support unpractical.
  • Some framework you use might already have a strong opinion you want to be consisent with.

Error context

Giving context with an error tends to be difficult with error codes and sentinel values. In the worst case, only one sentinel value exists (eg a NULL pointer in C) so the only useful information we can send is “this failed”.

Though there are techniques to provide more information, it is usually impractical, involving saving the error context in some global state area, possibly with thread-safety implications.

A word on monadic types

One way to overcome the limitations of error codes comes from functional programming.

The core idea is to create a type that is the sum of possible outputs and error conditions. That is, after constructing such a type, a variable of that type might take either of any possible output value or any possible error condition.

type Error = Int
type Value = String
type Result = Either Error Value

-- A function that returns a value
okFunc :: Result
okFunc = Right "Hello"

-- A function that fails
failFunc :: Result
failFunc = Left 42

-- Consumer that uses the result
format :: Result -> String
format (Left error) = "Error " ++ show error
format (Right value) = value

-- Demo
main = do
    putStrLn(format(okFunc))
    putStrLn(format(failFunc))
Monadic return type for error handling.

Here the Result type is always either an error code or a string value.

This approach has many advantages:

  • It solves the forwarding boilerplate problem, because simply forwarding the Result carries error information with it.
  • Attempting to access the value if Result holds an error will be detected.
  • It does not incur the complexities that come with exceptions.

Monadic types deserve a full article some day. They are not reserved to Haskell. For instance, C++17 std::variant is similar to Haskell's Either that we used in the example above.

Conclusion

Writing this I realised that the topic is much deeper than one blog article can cover. Each paragraph would deserve its own post. But it was important to bring closure to that short series, and maybe we will explore those in more depth later.