I wrote about this a little before, but I've touched upon it recently again in smaller projects. I'm a bit wary of introducing experimental patterns in larger projects, but my smaller personal projects are great testing grounds.
I was writing some code that did a lot of file I/O. The thing about file I/O is that there are errors being returned everywhere. Most if not all of these errors we don't care about. When they occur, we just give up and return them. Pass them onto the user - the user has to do something to fix it, like correct the file path or replace their hard drive.
I reduced my code by 25% (yes, that many err != nil
checks) by wrapping the I/O
functionality to use panic. Normally I'd have a shared panic handler at the request level
in my microservices, but in this case, I was writing a library. I don't think it's ever
okay for a library to panic. So what do we do?
Simple, catch the panic before returning from any exported function. Any exported function that can error looks like this:
func Foo() error {
return errorcat.Guard(func(cat errorcat.Context) error {
// Do the work
return nil
})
}
The Guard
function wraps the call in a panic recovery process. And then you make other
wrappers like so:
// Binary write
func bwrite(cat errorcat.Context, w io.Writer, data any) {
cat.Catch(binary.Write(w, binary.LittleEndian, data))
}
Then your code looks like this:
func (source *Source) Export(w io.WriteSeeker, dataOnly bool) error {
return errorcat.Guard(func(cat errorcat.Context) error {
if !dataOnly {
bwrite(cat, w, uint16(len(source.Data)))
bwrite(cat, w, uint16(source.Loop))
}
bwrite(cat, w, source.Data)
if !dataOnly {
if len(source.Data)&1 != 0 {
bwrite(cat, w, uint8(0))
}
}
return nil
})
}
Instead of this:
func (source *Source) Export(w io.WriteSeeker, dataOnly bool) error {
if !dataOnly {
if err := bwrite(w, uint16(len(source.Data))); err != nil {
return err
}
if err := bwrite(w, uint16(source.Loop)); err != nil {
return err
}
}
if err := bwrite(w, source.Data); err != nil {
return err
}
if !dataOnly {
if len(source.Data)&1 != 0 {
if err := bwrite(w, uint8(0)); err != nil {
return err
}
}
}
return nil
}
All that err
checking does is add needless noise. I/O errors are hardly ever
recoverable. And worse, you can forget to check an error, and have it silently cause
havoc and lead towards the billion dollar mistake.
Out of the box, Go won't warn you if you ignore a return value.
The error-panic pattern also catches actual, real panics. So if you do something stupid like read past the end of a slice, it will turn that into an error, and the consumer can benefit from an additional safety net. Basically, your library will never panic past that barrier.
The guard context is a newer concept of mine. Basically, it helps you to track what functions can actually panic. That way, when writing a library, you never forget to have a recovery context for functions that can fail with the panic pattern. Otherwise, you might be tempted to wrap everything that is exported, just to be safe, when you don't need the guard in many cases. When the context is a required parameter for any function that can throw panics, then it becomes impossible to panic without the guard already in place.
See Errorcat on GitHub for a packaged implementation of the pattern. The README also details other advantages with the pattern.
It's also neat to note that, while many Go programmers may detest this usage of panic for error handling, the pattern is actually described in Defer, Panic, and Recover from 2010 on the Go Blog, which points out that the standard library uses the same pattern to condense tedious error handling in certain packages.
A snippet from the json encoder, for example, does not have an error return, and uses the passed in state to bubble errors upward via the "error" function:
func (bits floatEncoder) encode(e *encodeState, v reflect.Value, opts encOpts) {
f := v.Float()
if math.IsInf(f, 0) || math.IsNaN(f) {
e.error(&UnsupportedValueError{v, strconv.FormatFloat(f, 'g', -1, int(bits))})
}
...
The panic is captured later and translated into an error response.
Overall, I think having error
as a normal return value was a mistake in the design. Now
we have so much code based on that practice, and Go 1.x needs to be backward compatible
with all of Go code. What I think would be great is some syntactic sugar for bubbling
errors. I saw that the Go team is currently discussing a proposal on reducing error
boilerplate. It suggests this Rust-like
syntax, among other conveniences:
bwrite(w, uint8(0)) ?
For this example, if an error is returned from bwrite, then the "?" at the end would cause
the function to return the error at that point. Any other return values would be filled
with defaults, exactly the same as your typical if err != nil
check with a return. The
proposal also covers optional error blocks, executed when an error is present. Hopefully
we'll get some new nice things like this soon!