Error Handling in Go: There is No Try

Published 2019-07-22

I've been writing Go for the past five or six years and during that time I have seen many newcomers to the language post their opinions on the language around the internet. Often, these programmers bring some opinions on language design from established languages like Java or Python, but there are also newer opinions from languages like Rust, too.

Two topics I see a lot of noise about are error handling and generics. I may talk about generics in another post but for now I'll talk about error handling. The general complaint about error handling is that it is verbose.

The Go team had proposed a change to the language called try that was intended to simplify the error syntax in Go so that the common syntax like this:

    data, err := json.Decode(raw)
    if err != nil {
        return err
    }

might be replaced by this instead:

    try data := json.Decode(raw)

There was a great deal of discussion and just recently the Go team rejected the proposal for try.

However, the Go team have noted on many occasions that they hear complaints that Go's error handling is too verbose, and that there are still aspects of error handling that may receive some attention.

In my opinion, the try syntax does not really simplify anything, and while it saves some typing the extra typing is not onerous to begin with. It's better to adhere to "explicit is better than implicit" popularized by Python, or "clear is better than clever", if you prefer Dave Cheney's version, than to save a few keystrokes here and there.

There are two aspects of Go's error handling that I think are particularly cumbersome: wrapped errors and multiple errors.

Wrapped Errors

When you call code in Go that calls another function, you may get an error from the function you are calling, but you don't know if it originates from the named function or from further up in the call stack. For example:

func (o *OvenControl) Start() error {
    if err := o.OpenGasValve(); err != nil {
        return err
    }

    if err := o.Ignite(); err != nil {
        return err
    }

    return nil
}

func bake(temp int, cookTime time.Duration) error {
    if (temp < 180) {
        return errors.New("temperature is too low, must be >= 180")
    }

    if temp > 500) {
        return errors.New("temperature is too high, must be <= 500")
    }

    oven := &OvenControl{
        Temperature: temp,
    }

    if err := oven.Start(); err != nil {
        return fmt.Errorf("problem with oven: %s", err)
    }

    ...
}

func makeACake() err {
    if err := mixIngredients(); err != nil {
        return fmt.Errorf("failed mixing ingredients: %s", err)
    }

    if err := bake(); err != nil {
        return fmt.Errorf("failed baking cake: %s", err)
    }

    return nil
}

When we get to the bake step, we may encounter an error because the gas is turned off or the electricity is out, but we won't actually see that error until we try to start the oven.

We could also imagine a situation where mixIngedients succeeds but then the electricity goes out and bake fails. Without wrapping the error message we don't have a way to know where that error happened. So we add those short messages -- "failed mixing ingredients" and "failed baking cake" to help us identify where the error is coming from.

Technically errors in Go are interfaces, but in practice most things use errors.New() or fmt.Errorf(), which are essentially strings. While it's possible to define your own error types that provide other information, the type disappears as soon at it is wrapped and converted into a string. Even if the type does not disappear, using the extra fields requires extra casting and error handling code in the caller so it is not straightforward and (in my experience) not widely used outside the standard library.

Basic Fixes

If you use errors.New() you can easily reference an exported error variable like this:

var ErrBadTemperature = errors.New("temperature out of range")

This approach allows you to do a direct comparison == to the specific type of error, and even use it in a switch statement. However, this approach precludes you from adding any additional information to the error message, such as indicating the parameter that is out of range or saying what the range is.

Additionally, the conventional wisdom in Go is "don't handle errors by doing string comparison". In some cases this is really the only way to do things, and in many cases it's the simplest way to do things. It's known to be brittle, but pragmatism wins out over purity.

If you attach a debugger of course you can see all these things, but when troubleshooting production systems, reading log messages, or showing user-facing error messages you cannot rely on a debugger.

Remaining Problems

Let's pause here for a moment and make a note of the problems we've encountered so far:

  1. Basic errors do not contain enough information
    1. Call context (i.e. where did the error come from?)
    2. Error parameters, such as function arguments or limits
  2. Can't compare errors created with fmt.Errorf()
  3. Wrapping errors using fmt.Errorf() causes error type information to be lost
  4. There is no standard for attaching additional information to errors

The Go community has various answers to these problems, based on reflection, context, logging, etc. but they are all tacked-on, not first-class features of Go, and are all significantly more complicated. If you take a look at the experimental xerrors package you can see one idea to solve this in a reusable way using reflection, much like the json and flag packages work.

This approach takes a similar shape as many of the errors in the standard library, which do use custom fields and then also use a Error method to turn then back into a helpful error message, complete with their parameters and context. For example:

const OvenMaxTemp = 500;
const OvenMinTemp = 180;

type TemperatureError struct {
    Temperature int
}

func (t *TemperatureError) Error() string {
    return fmt.Sprintf("%d is out of allowed range from %d to %d",
        t.Temperature, OvenMinTemp, OvenMaxTemp)
}

Maybe this will catch on over time, but the approach strikes me as messy because you are forced to do type introspection (using a complicated feature of Go's runtime) at a time when the program is already not working as you expected it to, quite a bit more work to define all the custom error types, and all this without any help from the compiler.

By and large people make do with errors.New and fmt.Errorf because they work well-enough most of the time, and they are widely compatible with the standard library and third-party libraries. The trade off for doing something different is significant because you risk being incompatible with major parts of the Go ecosystem.

Go currently handles 90% of the use-cases with 10% up-front effort, and it is not clear to me that trying to solve the last 10% of use-cases is worth spending 10x more effort on it.

Even in the case of os.IsNotExist() which involves several layers of indirection, custom error types, and code generation, it's not clear that there is a great answer here. There is an answer. Go handles it fine. It's just ugly. So we'll ultimately have to dispense with our sense of aesthetics and be pragmatic about it. It works. Move on.

Multiple Errors

When you are validating complex input or handling any data in a loop, you are likely to encounter multiple errors and want to handle them in a batch. In these cases you don't want to aggregate the errors into a single error because that loses context and doesn't give the user a clear idea of what's wrong -- you just want to pass back multiple errors.

However, you also don't want to have to write this interface, because the error handling code on the calling side is ugly. A 1-line idiom in Go turns into 4.

func Validate() []error {
    ...
}

func main() {
    errs := Validate();
    if len(errs) > 0 {
        for _, err := range errs {
            if err != nil {
                // handle error
            }
        }
    }
}

Instead it might be nicer to do this:

func main() {
    if errs := Validate(); errs != nil {
        for _, err := range errs.All() {
            // handle error
        }
    }
}

Like try, the savings of keystrokes and enhanced aesthetics here are, in my opinion, not significant enough to warrant a major change to the language.

Fortunately there is a useful library which tackles this problem. It is not part of the standard lib, and the library introduces some of its own idiosyncrasies, but I think we can at least say this one is a "solved problem". Of course I would prefer not to import a third party library to do error handling, but this is not a language limitation at least.

Other ways of solving this problem (such as rearchitecting your application to use stream processing instead of batch processing) are typically very complicated, and are not always warranted just to solve the problem of error handling complexity.

But, again, I think I'll just keep the simpler, uglier code for now and judge my application on whether it works correctly, rather than the aesthetic properties of the source code itself.

Aesthetics in Programming

I've heard a sentiment from many programmers, something that goes along the lines of "s/he writes beautiful code" or "this code is really nice to read". Likewise, a lot of the complaints about programming languages in general, and Go in particular, are about aesthetics. Curly braces, significant whitespace, the many parens of scheme, verbose error handling, and the strange arrows found in FP languages offend our sensibilities.

The try proposal was a bit of syntax sugar for a common idiom. One reason the try proposal was rejected was that the try construct would need to be rewritten if you wanted access to the err variable. Is it worth the effort to hide those things?


Related

golang