Ideal Language

Do we need 3, or just 2 error types?

Error handling is hard, and it’s made harder by a rich error hierarchy. Programs that successfully handle errors tend to have only a couple of generic handlers. Code that catches specific types of errors, in numerous locations, and tries to differentiate its handling, ends up going wrong. How can we provide both rich error information and simplified handling?

How many error types do we need? It looks like just 2, or maybe 3.

Error information vs. severity

When an error occurs, we rightfully want to collect as much information as we can about it. Was an argument out of range, could the file not be loaded, was the network down, or is there anything worth noting? This information is both valuable in debugging and giving meaningful error messages to a user. But this information is one of the reasons why we’ve ended up with bloated error hierarchies.

But this extended information is orthogonal to the type of the error. The handler cares only about the severity, not the details.

try {
} catch( recoverable_error & re ) {
    log( re );

What does the error require of the calling code? Can we do a simple clean up or do we need to abandon further processing? By mixing the extended information in with the error type, we’ve made this decision hard. Not only do we have way too errors to chose from we have to deal with various wrapping classes that hide the underlying error.

In an environment with a rich exception hierarchy, like Java, C#, and even most C++, the only useful handling is to catch all exceptions!

The error mechanism is not relevant to this problem. Whether a language uses exceptions, return values, or monads, it is still subject to a proliferation of error types.

Tagging errors

There is a solution to the information problem. I first saw it in the Boost exception library for C++. Instead of creating an endless number of exception types, it uses a tagging mechanism. We can add arbitrary details to any exception without changing its type.

if( !valid_input(data) ) {
    BOOST_THROW_EXCEPTION( recoverable_error() <<
        tag_reason( "invalid-input" ) << 
        tag_input_data( data ) )

This code uses a rather generic recoverable_error. It adds a tag_reason, saying what went wrong, and a tag_input_data, referencing the source data. We’ve created a detailed error without modifying the type of the exception.

There’s no need to do wrapping in this approach either. Handlers can add more information directly.

try {
    auto data = load_file( name );
    return parse( data );
} catch( recoverable_error & re ) {
    re << tag_filename( name );

We keep the same recoverable_error type and have added a tag_filename to it. This error now has a tag_reason, tag_input_data and tag_filename on it. We’ll print all of them to the log.

I consider tagged errors a robust solution but have not seen them outside of using Boost exceptions in C++.

Severity levels

If we don’t need new types to carry details, what error types do we need?

I’ve argued this at length with a few colleagues. We’re still in disagreement on whether it is two or three types. Yes, those are both shockingly low numbers!

One of those error types is easy to support: the critical error. These are situations that can’t be handled correctly. Maybe when a severe failure has essentially broken the system, like detecting memory corruption, the inability to allocate a small object, or a VM security violation. Many C libraries call abort on such errors. We can propagate these normally, but only to get more information for debugging — they are not recoverable!

There’s no question that a critical error type exists, so the interesting question is whether we have two or just one addition error type?

The two types: stateless vs. stateful

I’m in favour of two types:

  • Stateless/unwindable errors: These are things like argument checks and pre-validation. They happen before any state in the system has been modified. The caller’s state will be exactly as it was before the failed function call.
  • Stateful/recoverable errors: These happen after something has been modified already. The caller has to assume that the objects they are using, involved in the call, are not in the same state and must be cleaned up.

These give the caller clear direction in what options they have for handling the error. An unwindable error can be caught at any point and execution can proceed as though it didn’t happen. A recoverable error requires the caller to cleanup before continuing.

Functional programming offers another view on these two errors. If we use only pure functions, we can’t have the stateful class of errors. Unfortunately, a program can’t be built from pure functions alone, but it’s a thoughtful view to keep in mind.

The argument against the two type approach goes roughly as follows: programmers are just going to mess it up.

It sounds trivial, but it’s a compelling argument. Handling these error types is not the problem. The problem is raising the errors. Do I expect programmers to know when it’s appropriate?

More than likely a programmer will be cautious and only raise the “stateful / recoverable” type since they aren’t confident they haven’t modified anything. It’s always safe to raise a stateful error, but incorrectly raising a stateless error can be disastrous.

Worse, a stateless error in one context may become a stateful error in another. It depends on what the previous call path has already done. Not only would we need to get the source call sites correct, but we’d also have to worry about the escalation at the right times.

There’s no way this approach could work unless the compiler did most of the work.

In Leaf

But I’m writing a compiler, so maybe the idea can work. Can the compiler decide what type of error to raise, and do the required escalations? It seems like it should be possible. The compiler knows about all the memory involved, and all the assignments performed. Surely it can detect whether an error is stateless or stateful.

In practice, this is a problem. In a language that supports global memory, shared values, closures, and mutable caches, it’s not easy to determine whether a function call has observable side-effects. It’s not impossible though.

I think I’ve just eroded support for my own argument. Providing stateful and stateless errors would be onerous. Unless I want to spend the next half year on this problem alone I can’t realistically implement my idea. However, since the compiler has to do it all, it’s something I could introduce later and be completely backwards compatible.

4 replies »

  1. What about letting the compiler insert information into the raised error that informs the handler of the environment being tainted or not?

    As an example:
    try { do_something_impure(); }
    catch (error_name) { print(error_name.pure); }

    would print false.
    try { int x = 1 + pure_function_that_could_fail(30); }
    catch (error_name) { print(error_name.pure); }

    would print true.

    This would prevent programmers from raising the wrong errors, the compiler infers the error’s state by ensuring that all code before the throw/raise statement is pure.

  2. Yes, this is what I want to do, but it gets difficult. `do_something_impure()` could in theory still fail prior to doing something impure, like checking it’s arguments (if it had any). Or you may have sequence of operations:


    The compiler would have to label all functions are pure or impure, it can’t deal with “maybes”. As soon as an impure one successfullly completes it’ll have to mark the whole block as impure now (any error will be impure). This is true even if the following functions raise pure errors. This is escalation I talked about in the article.

    With shared values it becomes tricky:

    do {
    var q : shared = load_vector()
    } on fail {
    //always pure

    The exception is always pure here since no state escaped that block. It doesn’t matter than `q` is an impure function — it was modifying local data only. The compiler needs to tracks the lifetime of all variables involved and only if modifications escape the block is the error impure… except, well:

    do {
    var q : shared = load_vector()
    } on fail {

    There are some functions, like `save_to_db` that access global state which even though using a local variable will allow state to escape. This implies there are more divisions to purity than just “pure” and “impure”.

    I’d like to do this eventually, but I don’t want to get caught up on it now.

  3. Seems like you are looking for an effect type system, where you would be able to distinguish error results with side-effects (or not).
    so eg. a function with a side-effectless exception would be (in Haskell-like notation, with [] indicating the effect type)

    funPureError :: Int -> Exception | Int [SideEffect]

    vs. an exception with a side effect

    funImpure :: Int -> Exception [SideEffect] | Int [SideEffect]

    If you’d then use funPureError in a impure function, assuming the effects are (type-)inferred, you’d get an “impure” exception return type as a result.

    …though most languages don’t support anonymous union types so you’d need a named parametric (in both return value and side effect types) “result” type, but the principle still applies.

    • Side-effect notations as part of the function signature are defeinitely related to this discussion. They feel orthogonal in some ways, but completely intertwined at others, as you show.

      One problem is that the side-effects can vary depending on what the error occurs. Impure functions that do argument checking can fail prior to any side-effects.

      I’m free to decide how to implement this in Leaf, so language limtiations aren’t an issue. Side-effects and error types will become inferred aspects of the function (though errors are implied by default, requiring an explicit keyword to indicate they can’t happen).

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s