Turns out, with generics coming with Go 1.18 we can implement type-safe Either, Maybe, and many other useful containers.
This means we can even pretend that we don’t have if err != nil
idiom and chain operations like functional programmers do. Just like in Scala or Haskell.
Compare classic and functional example below:
package main
import (
"errors"
"fmt"
)
// We make cake from grain we have in our granary. It's a 3-stage pipeline.
// Each stage of the pipeline can fail.
// We can handle errors in a classic Go way
// OR
// We can handle errors in a functional way utilizing generics
func main() {
runClassicPipeline()
runFunctionalPipeline()
}
func runClassicPipeline() {
// Each stage has its own error handling:
grain, err := GetGrainClassic()
if err != nil {
fmt.Println(err)
return
}
flour, err := MillGrainClassic(grain)
if err != nil {
fmt.Println(err)
return
}
cake, err := BakeCakeClassic(flour)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(cake)
}
func runFunctionalPipeline() {
// Stages are chained together in one expression:
result := Map(
BakeCakeFunctional, Map(
MillGrainFunctional,
GetGrainFunctional()),
)
if result.IsSuccess() {
fmt.Println(result.Get())
} else {
fmt.Println(result.Error())
}
}
// GetGrainFunctional returns a number of grain bags we got from a granary.
func GetGrainFunctional() Result[int] {
// if you want to see success:
// return Success(1)
return Failure[int](errors.New("grain is not available"))
}
// GetGrainClassic returns a number of grain bags we got from a granary.
func GetGrainClassic() (bags int, err error) {
// if you want to see success:
// return 1, nil
return 0, errors.New("grain is not available")
}
// MillGrainFunctional mills grain and returns flour weight (in grams).
func MillGrainFunctional(grainBags int) Result[float32] {
return Success(1000*float32(grainBags))
}
// MillGrainClassic mills grain and returns flour weight (in grams).
func MillGrainClassic(grainBags int) (float32, error) {
return 1000*float32(grainBags), nil
}
// BakeCakeFunctional bakes a cake from provided flour.
func BakeCakeFunctional(flourWeight float32) Result[string] {
if flourWeight < 100 {
// Bake a small cake.
return Success(fmt.Sprintf("HB!"))
}
// Enough flour to make a full inscription.
return Success(fmt.Sprintf("Happy birthday!"))
}
// BakeCakeClassic bakes a cake from provided flour.
func BakeCakeClassic(flourWeight float32) (string, error) {
if flourWeight < 100 {
// Bake a small cake.
return fmt.Sprintf("HB!"), nil
}
// Enough flour to make a full inscription.
return fmt.Sprintf("Happy birthday!"), nil
}
To make it work we need a little library. The key function of the library is Map
. The key type is Result
.
type Result[T any] interface {
IsSuccess() bool
Get() T
Error() error
}
type success[T any] struct {
v T
Result[T]
}
func (s success[T]) IsSuccess() bool {
return true
}
func (s success[T]) Get() T {
return s.v
}
func (s success[T]) Error() error {
panic("a success doesn't have an error")
}
type failure[T any] struct {
err error
Result[T]
}
func (f failure[T]) IsSuccess() bool {
return false
}
func (f failure[T]) Get() T {
panic("cannot get result of a failure")
}
func (f failure[T]) Error() error {
return f.err
}
func Success[T any](value T) Result[T] {
output := new(success[T])
output.v = value
return *output
}
func Failure[T any](e error) Result[T] {
output := new(failure[T])
output.err = e
return *output
}
// Map calls f on fArg if fArg is a success, otherwise it adapts error to f's returning type.
func Map[T any, R any](f func (T) Result[R], fArg Result[T]) Result[R] {
if fArg.IsSuccess() {
return f(fArg.Get())
}
return Failure[R](fArg.Error())
}
It’s fun to see that it compiles and works. I don’t think it’s a good idea to actually use it in serious Go code. Serious Go code must be dead simple.