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 Option
is being used in continuous chain-like operations that involve using one Option
value 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 say? Join the discussion below 👇
Want to explore instead? Fly with the time capsule 🛸
You may also find these interesting
Implementing a Generic Filter Function in Go
This article will demonstrate the implementation of a generic slice filter function using the new type parameters syntax.
How to Use Generics in Go Starting From v1.17
Using a flag that appears to have been brought to light with v1.17
Between Go and Elixir
Reason wanted me to make a choice, and I am so glad I didn’t. Because the more I kept delving into both Elixir and Go, the more I found out how complementary the two can be to one another.