A simple result type in Golang 1.18

bobfang1992_0529

Bob Fang

Posted on December 14, 2021

A simple result type in Golang 1.18

With the introduction of generics in Go 1.18. One of the pain point I had with Golang has been finanly resolved.

In my day job, there are a lot of times I need to implement a "map-reduce" style algorithm using goroutines. Some thing like this:

func processing() {
    works := []Work{
        {
            Name: "John",
            Age:  30,
        },
        {
            Name: "Jane",
            Age:  25,
        },
        ...
    }

    var wg sync.WaitGroup

    result := make(chan ProcessedWork, len(works))
    for _, work := range works {
        wg.Add(1)
        go func(work Work) {
            defer wg.Done()
            // do something
            newData, err := doSomething(work)
            result <- newData
        }(work)
    }

    wg.Wait()
    close(result)

    for r := range result {
        // combine results in some way
    }
    return ...
}
Enter fullscreen mode Exit fullscreen mode

This may looks like all good, but there is a problem. What if one of the sub goroutine failed, what if this line actually return an error?

newData, err := doSomething(work)
Enter fullscreen mode Exit fullscreen mode

So usually you have two options, one is to simply introduce a seperate error channel, and the next is to introduce a new Result type locally and change the result channel here to accept the Result type. In this example we can do this:

type Result struct {
    Data ProcessedWork
    Err  error
}
result := make(chan Result, len(works))
Enter fullscreen mode Exit fullscreen mode

I usually opt for the second solution, but having to define this type every time is a pain. There were some suggestions to use interface{} to represent the Data and do type assertion when using the data, but generally I am not a big fan of using interface{} in Go.

Luckily we got generics in Go 1.18, so our Result type can be defined using this feature.

type Result[T any] struct {
    value T
    err   error
}
Enter fullscreen mode Exit fullscreen mode

Having this type is much better than using ad-hoc Result types for each processing function in the code base.

A number of useful methods can be added to the Result type, for example

func (r Result[T]) Ok() bool {
    return r.err == nil
}

func (r Result[T]) ValueOr(v T) T {
    if r.Ok() {
        return r.value
    }
    return v
}

func (r Result[T]) ValueOrPanic() T {
    if r.Ok() {
        return r.value
    }
    panic(r.err)
}
Enter fullscreen mode Exit fullscreen mode

There are some obvious things I want to point out though.

  1. Golang does not do sum type yet, there are proposals to add this, but I think it will come much later. Currenlty the best way to emulate a sum type in Golang is to simply use a struct and remeber which field you have used, like what we did here with the Result type. Other languages call this discrimated union if you include a special field which denote the field being used.
  2. Result type, no matter what method you use to implement it, is not a new concept. C++ has std::optional since C++17. Rust, the golden boy of this era, has Result<T, E>. Haskell, where I first learned the conecpt of using types to represent the outcome of an operation that may or may not success, has Maybe.

Further to point 2, another thing to notice is that Result is actually a monad, C++23 recently added monadic operation for std::optional, and Hasekll has the following functions in its stdlib since long before this conecpt is popular.

return :: a -> Maybe a
return x  = Just x

(>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
(>>=) m g = case m of
                Nothing -> Nothing
                Just x  -> g x
Enter fullscreen mode Exit fullscreen mode

The nice thing about these two functions, especially for the second function (>>=), is that it allows you to easily combine/chain multiple operations that may or may not yield a result together without the need to keep using if to check if the result of last operation is Ok or not. I am not going to enumerate an example, but if you are curious you can look at the Hasekll example here.

But Golang is a bit lacking, at this moment, if everything goes according to the plan, then we will not be able to have another idependent type variable in a generic type's method. So we cannot have something like this:

func (r Result[T]) Then(f func(T) Result[S]) Result[S] { // <-- S is not allowed, we can only use T
    if r.Ok() {
        return f(r.value)
    }
    return r
}
Enter fullscreen mode Exit fullscreen mode

IMO, this restriction quite serverly limited the usefulness of the result type.

Another thing I wish we had is C++ style "partial specialization" in Go's generics. For now the constraints for Result is any, but I do want to provide a function like this for user:

func (r Result[T]) Eq(v T) bool {
    if r.Ok() {
        return r.value == v
    }
    return false
}
Enter fullscreen mode Exit fullscreen mode

But since T is not comparable, the == operatior will not work. If Go can provide a way to refine the constraints for some methods of a generic type, then it would be a nice feature. E.g.

// here we refined the T type from any to comparable by providing a more precise constraints in the method receiver type
// now only Result that holds a T that are in the constraint comparable will have this method enabled.
func (r Result[T comparable]) Eq(v T) bool {

    if r.Ok() {
        return r.value == v
    }

    return false
}
Enter fullscreen mode Exit fullscreen mode

Sample code can be found here

💖 💪 🙅 🚩
bobfang1992_0529
Bob Fang

Posted on December 14, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related