Get Going: First impression of Go error handling

I've started using Go in production systems. So far I quite like it. Go has its own Gopher's ways to do things. Among those error handling is an unique one. As robust error handling is essential in real systems and the way Go treats the error is very different from traditional exception that's used in other languages. So here I go trying to have an initial summary of Go's error design philosophy with my recent experience.

▪︎ Explicit Return

Just like other value types, errors are just values. So it can be assigned, compared and returned. It does not require different keywords to deal with, i.e throw in traditional exception handling. The built-in error is an interface with only one method:

type error interface {
    Error() string
}

So in its simplest form an error variable can be anything that implements this Error() method and return a string which is the error message. We could see lots of built-in methods actually have the signature like below:

func ReadFile(name string) ([]byte, error)

From return value we can tell if this function could go wrong. This leads to the next explicitness:

▪︎ Explicit Checking

Because error is part of your method contract, it forces us to treat error in the first place before trying to access the normal result: either we propagate by simply returning or handle it to do some extra logic.

data, err := ReadFile("file.txt")
if err != nil:
  return err
  // or handle it e.g logging, return error response..

Unlike exception handling, error will implicitly propagate to the nearest catch block in the call chains to get "handled". But this could become less thoughtful about potential errors (under-handling), by just blindly "bubbling" up. I've seen a lot that developers just put a try...catch in the upper level call chains, without knowing what the error is and where it is originated. It's fragile and less predictable. The only way is to check the runtime long stack trace.

What's the trade-off?

With errors as simple values being returned and checked explicitly, It becomes verbose. As many things could go wrong in real systems, your Go codebase could quickly pile up with dozens of error checking until reaching the happy path.

Sure we could ignore by using _ ,

data, _ := ReadFile("file.html")
// just go with happy path
process(data)

But I think no one is comfortable seeing lots of _ in real trying to ignore it. In the end the real effort in all robust systems is to take care of all "unhappy" paths.

To be honest I don't think this is caused by Go error design, as it's important to think of errors in the first place. This could happen to traditional exception as well if we try to wrap every possible error with try...catch block.

The solution to the verbosity actually depends on how we structure the code. If we could have more coherent packages with errors properly encapsulated, (which we need anyway regardless of how the error is handled), we could avoid those boilerplate code in every step of the way. I plan to have another post going through this.

Another thing to notice, because the minimal error value could just contain a string message, it does not have stack trace attached. Also we could lose the original error context when propagating a new error to the upper caller. The way Go proposed to address this is through error wrapping. I haven't used it a lot in practice, but keep in mind to write some practical posts after dealing with it.

References

https://go.dev/blog/error-handling-and-go
https://go.dev/blog/errors-are-values