Generic Go Optionals


Got comments?

Disclaimer: The code that follows is neither an attempt at establishing post-generics best practices, neither am I am encouraging anyone to use it in their Go applications. It's simply a thought experiment at applying a pattern known as Option or Optional in other programming languages. The motivation behind it is providing a fluent approach to writing code that focuses on highlighting the "happy path", while being 100% idiomatic with Go's error handling convention.

Feel free to leave suggestions and comments at the end of the article.

Check this example:

func successFunc() (int, error) {
	return 10, nil
}

func erroringFunc(in int) (int, error) {
	return 0, errors.New("boom")
}

func successConvertFunc(in int) (string, error) {
	return "42", nil
}

func main() {

	opt := Try(successFunc()) // returns an Option
	opt = Unwrap(opt, func(in int) (int, error) { /* Simple funcs can also be inlined */ return in + 2, nil })
	opt = Unwrap(opt, erroringFunc) // The fun stops here. The error will be propagated back to the end of the chain
	opt2 := Unwrap(opt, successConvertFunc) // This func won't even be called

	//
	res, err := opt2.Res()
	if err != nil {
		panic(err)
	}

	fmt.Println(res)
}

In many situations, Go code needs to deal with multiple functions that can potentially return either a result, or an error. The standard practice in such cases is to return a `result, error` pair, and leave it to the caller to handle a possible error. This naturally leads to a familiar pattern:

res, err := func1()
if err != nil { /* handle the error or return early */ }

res, err = func2(res)
if err != nil { /* handle the error or return early */ }

res, err = func3(res)
if err != nil { /* handle the error or return early */ }

// ... and so on ...

While the code above is perfectly fine, one might argue that handling the errors manually in complex bits of code may obscure the business logic.

This is where introducing a tiny generic abstraction on top of the `result, error` pair might come in handy. The so called `Option` or `Optional` as it is known from languages like Rust and Java is simple and contains no magic or exception cases that are not immediately visible to the programmer. It is fully idiomatic, in that it forces the developer to perform an error check when one ask for the result value stored in the Option. However, it also supports graceful error handling when the Optionis being used in continuous chain-like operations that involve using one Optionvalue and producing another (also known as "unwrapping").

The Code

For those eager to see the code, I am providing it right away. The section after that contains a few notes, explanations, and observations.

// Option represents a value that may be present or not.
type Option[T any] interface {
	Res() (T, error)
}

// optionImpl is a concrete implementation of Option.
// A less Java-like name suggestion is more than welcome.
type optionImpl[T any] struct {
	val T
	err error
}

func (o optionImpl[T]) Res() (T, error) {
	return o.val, o.err
}

// Try is a convenience function for creating an Option from a function that may return an error.
func Try[T any](val T, err error) Option[T] {
	return optionImpl[T]{val: val, err: err}
}

// TryErr is a short form of Try that directly returns an Option containing an error
func TryErr[T any](err error) Option[T] {
	return optionImpl[T]{err: err}
}

// Unwrap is a convenience function for unwrapping an Option.
// If the Option is an error, the error is propagated back to the end of the chain.
func Unwrap[T, K any](o Option[T], fn func(T) (K, error)) Option[K] {
	val, err := o.Res()
	if err != nil {
		return TryErr[K](err)
	}

	return Try(fn(val))
}

Explanation

The core type is an interface called Option, having a single requirement:

type Option[T any] interface {
	Res() (T, error)
}

This makes it easy for consumers to implement an Option in whichever ways they see fit.

There are two other important cases here, provided as simple package-level functions:

func Try[T any](val T, err error) Option[T]

This function can directly take the result/error pair coming from a standard Go function, and produce an Option instance

The second one is Unwrap:

func Unwrap[T, K any](o Option[T], fn func(T) (K, error)) Option[K]

Unwrap is where the fun with optionals happens. It would take an Option instance, as well as a continuation function, resulting in a new Option instance. Depending on whether the continuation function returns a value or an error, the new Option instance may serve as a fast-track error propagator in long call chains (see the example).

NOTE: Why is Unwrap a package function and not an Option method?
Indeed, it would have been much more convenient to have Unwrap be a method on an already created Option instance. Howeve, at the moment, there is a limitation of the current implementation of generic type parameters. For the same reason, func(T) (K, error) cannot be extracted into its own convenience type that implements Option.

Improvement suggestions are more than welcome.

Have something to add? Leave a comment below.