Experimenting with Go Generics

Starting with version 1.18, Go supports generic type parameters. Before this, we were forced to perform some syntactic gymnastics to reuse code effectively. Given that version 1.18 only became generally available in March of 2022, I haven’t had a chance to play with them.

Suffice to say that I have decades of experience with generics in other languages. From a Go perspective, I wanted to see if they could provide some relief to some of the verbosity that is inherent with Go.

An Example

Let’s look at a simplified example of some error handling1as we wait for what may come of the error handling proposal for Go 2. In the following contrived example, we are trying to get the absolute path of a .cache directory relative to the running binary, then we attempt to create that path2and any intermediate directories.

cachePath, absPathErr := filepath.Abs(".cache/")
if absPathErr != nil {
	log.Panic().
		Err(absPathErr).
		Msg("error")
}
mkdirErr := os.MkdirAll(filepath.Clean(cachePath), 0750)
if mkdirErr != nil {
	log.Panic().
		Err(mkdirErr).
		Msg("error")
}

That is an awful lot of code just to create a .cache directory in the current working directory. As you can see, most of it is error handling and in this case, we want to bail if we hit an error. There are several approaches to simplifying this code, but I want to see if generics can help.

Ideally, I want to get this block of code down to two lines. Using generics, we can transform the above into the following

cachePath := mustReturn(asReturn(filepath.Abs(".cache/")), "error")
must(os.MkdirAll(filepath.Clean(cachePath), 0750), "error")

I created a new function called must which is fairly idiomatic for Go. A must function effectively ensures that some action is completed without error.

Now, these functions could be created without generics, but the mustReturn function is a bit more difficult. The mustReturn function ensures that the execution of the first argument succeeds without an error, but it also supports returning the non-error return value. Since the returned value could be any value, generics can help us.

It’s worth noting that most Go functions either return nothing, an error value, or some value in addition to an error value (the error value will be nil if there is no error). Go functions can have as many return values as necessary, but it’s typical to just see the values I mentioned above.

Utilizing Generics

To accomplish the above, I created the following

type ReturnValue[T any] struct {
	value T
	err   error
}

func asReturn[T any](value T, err error) *ReturnValue[T] {
	return &ReturnValue[T]{value: value, err: err}
}

func mustReturn[T any](result *ReturnValue[T], msg string ) T {
	if result.err != nil {
		panic(fmt.Errorf("%s: %w", msg, result.err))
	}
	return result.value
}

func must(err error, msg string) {
	if err != nil {
		panic(fmt.Errorf("%s: %w", msg, err))
	}
}

The must function, is closer to what you would see in a Go project prior to 1.18. It simply takes an error as the first parameter. If it’s not nil, then the application will log a message and panic. This means that we can provide any expression that results in an error-typed value as the first parameter. You can see this highlighted below (2️⃣). The os.MkdirAll function returns an error-typed value and nothing else.

1️⃣ cachePath := mustReturn(asReturn(filepath.Abs(".cache/")), "error") 
2️⃣ must(os.MkdirAll(filepath.Clean(cachePath), 0750), "error") 

The more challenging line is the one where we define cachePath(1️⃣). One of the challenges here is dealing with the multiple return values. While functions can return multiple values, there’s no built-in concept of “tuples” in Go, so the multiple return values cannot be passed as-is. Instead, we need to wrap the return values using the asReturn function. This function will take a generic type (unconstrained using the any keyword).

Since filepath.Abs(string) returns (string, error), we need to deal with the multiple values. If the error is nil, then we need to bail; otherwise, we need to return the first returned value (which happens to be a string in this case).

To do this, we wrap filepath.Abs with the asReturn function. The asReturn function will marshal these values into a struct which can then be interrogated for the error or return value.

Conclusion

While none of this is breathtaking, it’s undoubtedly a welcome change to Go. I’m looking forward to finding ways to leverage this new functionality. For the time being, it’s still a relatively new feature and tooling such as linters have yet to catch up.

If you’d like to experiment with some of this yourself, I’ve made the above code available in the Go Playground.

Share