Code

Can Generics Rescue Golang's Clunky Error Handling?

January 10, 2022

One of the most complained about aspects of programming in Go is error handling. However - Go is getting generics! Generics have already been merged to master and are slated for inclusion in the 1.18 release. So can generics raise¹ Go’s error handling out of the doldrums?

The problem

Here’s some typical error-handling code:

str, err := foobar()
if err != nil {
  // handle error
}

The function foobar returns two values; the second one is an error. The condition checks whether err is defined, and if it is, branches into some error handling code (logging, early return, whatever is needed).

Several unfortunate properties can arise from this arrangement:

  1. Pollutive - Go code declares unused variables: foobar() returns either a string or an error, but both are declared by the calling code.
  2. Verbose - sequential function calls all perform the same dance: assign an error return value, test and conditionally handle it. Go has no exception handler mechanism.
  3. Fragile - mistakes can be hard to spot: Go function calls often re-use previously declared error variables or ignore them by assigning error return values to _.

One way that Go programs fail to scale well is in the writing of error-checking and error-handling code

Error Handling - Problem Overview²

One value but two types

Haskell’s Either type is a single value which can be one of two types. Callers then pattern-match on the value to take the appropriate action.

To replicate the behavior of an Either type, I created errval.go, a generic struct type which contains slots for an error and a value:

type ErrVal[A any] struct {
	err error
	val A
}

Even though the ErrVal struct should only wrap a value or an error, it has a slot for each. Since we have generics, why not use one slot for both? The trouble is there is no good way for the type to reflect and figure out if it is holding an error or not. Type switches only work for interfaces. And what would the generic type declaration look like? ErrVal[any] doesn’t tell the caller anything. We could declare a type like StringErr which would be a string or an error, but then we would have to declare a type for each variation (IntErr, …). That doesn’t sound very generic.

Regardless, instead of returning two values and having the caller declare both and test if the error is nil or not, functions can return a single ErrVal. ErrVal has two constructors: Err which takes an error, and Val which takes a generic value:

func foobar () *errval.ErrVal[string] {
	if mightFail() {
		return errval.Err[string](errors.New("some error message"))
	}
	return errval.Val[string]("foobar")
}

result := foobar()

This de-pollutes the calling code by not needing to have declared-but-unused variables.

Explicit Error Handling

I mentioned before how callers can ignore errors by assigning them to _. Another way is to ignore all return values, such as when a function is being called only for its side-effects:

w.Close()

Many Go projects use the linter errcheck to catch ignored errors. This is fine, but it’s a bandaid - imagine if the errors were explicitly handled by design. Then we wouldn’t need to check.

To enforce error handling, the only way to get the value out of an ErrVal is to call its Catch method. Catch takes an anonymous function error handler, and returns a boolean indicating success (no error) plus the value:

if ok, str := foobar().Catch(logger); ok {
	// do something with str
}

It’s interesting to ponder the implication of Go’s zero values here; Catch must return a bool because there is no way to test the value’s defined-ness.

Control Flow

The Go Error Handling Overview² has this wonderful example:

func CopyFile(src, dst string) error {
	r, err := os.Open(src)
	if err != nil {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}
	defer r.Close()

	w, err := os.Create(dst)
	if err != nil {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	if _, err := io.Copy(w, r); err != nil {
		w.Close()
		os.Remove(dst)
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	if err := w.Close(); err != nil {
		os.Remove(dst)
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}
}

It’s a great example because a) it’s real code; b) it’s long enough to demonstrate the current issues with Go error handling; c) it’s a stern test for proposed solutions.

Re-writing with ErrVal, I get:

func CopyFile(src, dst string) error {
	var err error
	fmtErr := func(e error) {
		err = fmt.Errorf("copy %s %s: %v", src, dst, e)
	}
	if ok, r := Open(src).Catch(fmtErr); ok {
		defer r.Close()
		if ok, w := Create(dst).Catch(fmtErr); ok {
			if ok, _ := Copy(w, r).Catch(func(e error) {
				fmtErr(e)
				w.Close()
				os.Remove(dst)
			}); ok {
				Close(w).Catch(func(e error) {
					fmtErr(e)
					os.Remove(dst)
				})
			}
		}
	}
	return err
}

Yikes. This code is almost as long as the original and frankly, hurts my eyes. ErrVal has made the code harder to read!

Now why is that? I think the reason is control flow - Go uses statements to control program execution, and programmers can only define expressions⁴. Potentially goto could be used to jump the thread of execution to a common exception handler, but Go’s goto statement is so neutered it can’t help here.

Go’s unsatisfactory error handling will require new statements³ then. Dedicated keywords are no doubt safer and more readable than programmers running amok with goto, it’s a shame Go doesn’t have them already.

Notes

  1. Silly exception pun 😉.
  2. Russ Cox, Error Handling - Problem Overview is a thorough examination of Go’s error handling.
  3. ibid, check/handle statements.
  4. I also experimented with methods like IfErr and Else which don’t help as you end up having to return one giant expression:
func CopyFile(src, dst string) error {
	return Open(src).IfErr(func(e error) {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}).Else(func(r *File) {
		defer r.Close()
		Create(dst).IfErr(func(e error) {
			return fmt.Errorf("copy %s %s: %v", src, dst, err)
		}).Else(func(w *File) {
			Copy(w, r).IfErr(func(e error) {
				w.Close()
				os.Remove(dst)
				return fmt.Errorf("copy %s %s: %v", src, dst, err)
			}).Else(func(_ int64) {
				Close(w).IfErr(func(e error) {
					os.Remove(dst)
					return fmt.Errorf("copy %s %s: %v", src, dst, err)
				})
			})
		})
	})
}

Tags: generics exceptions either errval