Thoughts on Whatnot
Experimenting with Error Handling via Generics in Go
June 06, 2020

On June 16th, an article was published to the Go blog detailing the next steps for generics in Go. Of particular interest in this case was that along with another updated draft design, this one came along with a tool that allowed users to actually write and use generic code in Go.

So with a request to experiment, experiment I did. I tried a few little things, such an implementation of iterators, but then decided to play around with error handling, and I quickly realized that generics alone may be the beginnings of the solution to the error handling problem that so many people have been clamoring for.

Before I get to error handling and what I did, let’s take a quick look at a slightly simpler case: Secondary boolean returns. In Go, the convention for a function that might not return a value is to have the function return a boolean as a secondary return that indicates whether or not the first value makes any sense. For example, in the os package, there is a function that does just that: func LookupEnv(name string) (string, bool). This function is a more powerful version of os.Getenv(). Both get the value of an environment variable, but os.Getenv() simply returns an empty string if the variable isn’t set at all, leaving no way to distinguish between an empty variable and an unset variable.

Taking a page out of Rust’s playbook, I decided to try implementing a wrapper for optional values that could provide some convenient common functionality. First, a container for the information and a few simple methods:

type Optional(type T) struct {
	v T
	ok bool
}

func (o Optional(T)) Or(v T) Optional(T) {
	if o.ok {
		return o
	}

	return Optional(T){v: v, ok: true}
}

func (o Optional(T)) Get() (T, bool) {
	return o.v, o.ok
}

func (o Optional(T)) Must() T {
	if !o.ok {
		panic("no value")
	}
	return o.v
}

It’s fairly simple to imagine how this might work, but how do you translate from existing functions following that convention to this new system? If I did, for example,

v, ok := os.LookupEnv("DATABASE_URL")
v = Optional(string){v: v, ok: ok}.Or(DefaultDatabaseURL).Must()

then I haven’t exactly gained a whole lot. I even have to manually specify the type of the value now.

The key is a shortcut in Go that allows a call to a function with multiple returns to be passed directly to a call to a function with multiple arguments of the same types in the same order. Given this, a function that can be created such as the following:

func Option(type T)(v T, ok bool) Optional(T) {
	return Optional(T){v: v, ok: ok}
}

Now usage is very easy:

// Functions do inference of arguments, so this version doesn't
// require a manual type to be specified.

v := Option(os.LookupEnv("DATABASE_URL")).
	Or(DefaultDatabaseURL).
	Must()

This is significantly cleaner, in my opinion, then a manual check of the boolean return. For one thing, it doesn’t clutter up the local namespace with booleans, something that can get quite awkward to deal with if calls are nested into several ifs, or if multiple calls need to be done before any of the checks are. For another thing, it avoids the subtleties of shadowing from using := with multiple assignments. And thirdly, it avoids the problems of trying to assign to a struct field or slice index with :=, which is simply not allowed.

From here, a basic error handling pattern can be established pretty easily, but before getting to that, I’m going to put together an example function that I’ll be attempting to clean up with some error handling wrappers:

func Authenticate(ctx context.Context, db *ent.Client, email string, password []byte) (user *ent.User, err error) {
	user, err = db.User.Query().
		Where(user.Email(email)).
		Only(ctx)
	if err != nil {
		return nil, fmt.Errorf("get user: %w", err)
	}

	dbpass, err := base64.StdEncoding.DecodeString(user.Password)
	if err != nil {
		return nil, fmt.Errorf("decode password: %w", err)
	}

	err = bcrypt.CompareHashAndPassword(dbpass, password)
	if err != nil {
		return nil, fmt.Errorf("check password: %w", err)
	}

	return user, nil
}

This function is a theoretical implementation of user authentication in, for example, an API backend. You don’t need to know how ent works to understand what I’m going to do here; you just need to recognize how the standard error convention is being used here.

Now that that’s out of the way, here’s the beginnings of the error handling code:

type Error(type T) struct {
	v T
	err error
}

func Try(type T)(v T, err error) Error(T) {
	return Error(T){v: v, err: err}
}

func (e Error(T)) Get() (T, error) {
	return e.v, e.err
}

func (e Error(T)) Must() T {
	if e.err != nil {
		panic(e.err)
	}
	return e.v
}

func (e Error(T)) Err() error {
	return e.err
}

Now, unlike for Optional, an Or() method doesn’t necessarily make as much sense, as there are two values to deal with. More importantly, the user will probably want to do something specific based on what the error value is if there is one. To that end:

type ModFunc(type T) func(T, error) (T, error)

func (e Error(T)) Mod(f ModFunc) Error(T) {
	return Try(f(e.Get()))
}

While this might be generally useful for a number of different ways of handling errors, there is one very common operation that can also be provided to the user:

// Not the most general, perhaps, but fine for the demonstation. Also,
// unfortunately, int its intended use the type will have to be
// specified manually because of shortcomings in the type inference as
// currently implemented.
func Wrap(type T)(str string) ModFunc(T) {
	return func(v T, err error) (T, error) {
		if err == nil {
			return v, err
		}
		return v, fmt.Errorf(str + ": %w", err)
	}
}

Now that these features have been added, let’s take a quick look at how it’s affected the working example:

func Authenticate(ctx context.Context, db *ent.Client, email string, password []byte) (user *ent.User, err error) {
	user, err = db.User.Query().
		Where(user.Email(email)).
		Only(ctx)
	user, err = Try(user, err).
		Mod(Wrap(*ent.User)("get user")).
		Get()
	if err != nil {
		return nil, err
	}

	dbpass, err := Try(base64.StdEncoding.DecodeString(user.Password)).
		Mod(Wrap([]byte)("decode password")).
		Get()
	if err != nil {
		return nil, err
	}

	err = bcrypt.CompareHashAndPassword(dbpass, password)
	if err != nil {
		return nil, fmt.Errorf("check password: %w", err)
	}

	return user, nil
}

Hmmm… Hasn’t helped much, huh? There are still err variables and err != nil checks all over the place. In fact, I might even go so far as to say that it’s gotten worse.

Don’t worry. I’m not quite done yet.

First of all, let’s take care of that call to CompareAndHashPassword() at the end. Unlike the others, this one doesn’t return a value, just an error. Thanks to the way Go’s type system works, this is surprisingly straightforward, only requiring a single new function:

func Do(err error) Error(struct{}) {
	return Error(struct{}){err: err}
}

That call can now be changed to

err = Do(bcrypt.CompareHashAndPassword(dbpass, password)).
	Mod(Wrap(struct{})("check password")).
	Err()
if err != nil {
	return nil, err
}

Alright, time to finally remove all of those manual checks. This is going to require a new type, a couple of modifications to an existing method, and two new functions. First of all, the type and the modification:

type perror struct {
	err error
}

func (err perror) Unwrap() error {
	return err.err
}

func (err perror) Error() string {
	return err.err.Error()
}

func (e Error(T)) Must() T {
	if e.err != nil {
		panic(perror{e.err})
	}
	return e.v
}

Now, when a function panics, before control leaves the function all of the deferred function calls are run as though a normal return was happening. Inside of those functions, a special built-in function, recover(), will cause the panicking to stop and will return the value, as an interface{}, that was passed to panic() in the first place. Combining this with the new perror type, it becomes fairly easy to write a function to catch errors that were caused by a call to Must() and only errors that were caused by that call:

type CatchFunc func(err error)

func Catch(f CatchFunc) {
	switch r := recover().(type) {
	case nil:
		return

	case perror:
		f(r)

	default:
		panic(r)
	}
}

And to avoid repetition:

func Return(rerr *error) CatchFunc {
	return func(err error) {
		if *rerr != nil {
			return
		}
		*rerr = err
	}
}

And back to the working example:

func Authenticate(ctx context.Context, db *ent.Client, email string, password []byte) (_ *ent.User, err error) {
	defer Catch(Return(&err))

	user, err := db.User.Query().
		Where(user.Email(email)).
		Only(ctx)
	user = Try(user, err).
		Mod(Wrap(*ent.User)("get user")).
		Must()

	dbpass := Try(base64.StdEncoding.DecodeString(user.Password)).
		Mod(Wrap([]byte)("decode password")).
		Must()

	Do(bcrypt.CompareHashAndPassword(dbpass, password)).
		Mod(Wrap(struct{})("check password")).
		Must()

	return user, nil
}

I don’t know about you, but that looks a lot cleaner to me, even with the insufficient type inference for the calls to Wrap().

So will generics fix the error handling ‘problem’? Maybe. It’s hard to tell at this point. But it will definitely be a step closer to that problem being solved.