Go: Error Handling in Functional Style

Published:
Translations:no translations yet

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.