Monads Can Simplify Go's Error Handling
A solution for Go's biggest issue, but at what cost?
February 24, 2023
Roughly a year ago I wrote about my attempts to tackle Go’s error handling problem using generics. I wasn’t very successful:
This code is almost as long as the original and frankly, hurts my eyes.
Since then I’ve continued experimenting with the idea and think I have something worth sharing. But first, let me recap the problem with an example.
Parsing Truthiness
Many programming languages have the concept of truthiness, that decides whether or not an expression will evaluate as true or false in a boolean context. For example in C, the number 0 evaluates as false in an if
statement.
I wrote a Go program to parse a line of user input and print whether it is truthy or not. Here is how it’s run:
$ go run example/readtruthy.go
Enter a truthy value: 1
You entered a truthy value!
This is the source:
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
fmt.Print("Enter a truthy value: ")
input, err := readLine(bufio.NewReader(os.Stdin))
if err != nil {
fmt.Println(err.Error())
return
}
truthy, err := parseTruthy(input)
if err != nil {
fmt.Println(err.Error())
return
}
fmt.Printf("You entered a %s value!\n", truthy)
}
func readLine(r *bufio.Reader) (string, error) {
s, err := r.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSuffix(s, "\n"), nil
}
func parseTruthy(s string) (string, error) {
boolean, err := strconv.ParseBool(s)
if err != nil {
return "", err
}
if boolean {
return "truthy", nil
}
return "falsey", nil
}
Look at all that error checking! The code is riddled with variable assignments and control statements.
A Partial Solution
In Errors are values Rob Pike showed how to refactor a tedious series of Write()
calls and error checks using an object which turns the calls into a no-op as soon as it encounters an error:
func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
// only if I haven't seen an error yet
_, ew.err = ew.w.Write(buf)
}
Now for that case, this was an improvement for sure. But my truthy parser can’t use it as it doesn’t call one function repeatedly, but several functions with different signatures. AS it happens that is exactly the problem Scala’s Try solves.
The Try Monad
Monads. Not long ago it seemed like every other blog was just somebody trying to explain what the hell a monad is. And today it’s my turn. Cracks fingers.
J/K.
Scala’s Try
a is specialized case of the either monad. It can contain a value or an error. It’s cool that Go functions can return multiple values, but:
s, err := r.ReadString('\n')
Doesn’t ever return two values. If ReadString
succeeds, it returns the string and nil, and if it fails, it returns nil and the error. So what we really want is something like:
try := r.ReadString('\n')
That saves a variable assignment. Next we want to only call a function if try
contains a value and not an error. In functional programming, that’s what the “map” function does. So something like:
try := Map(r.ReadString('\n'), parseTruthy)
Map
will only call parseTruthy
if ReadString
succeeds. The return value will contain one of: the ReadString
error, the parseTruthy
error or the successfully parsed boolean.
We’re operating with expressions instead of control statements, which has two benefits:
The first is safety. Somewhere in our mythical try
type there is a single control statement that is perfectly unit tested. Imagine that - no branches in your code to test. This is a partial expression of the idea of “business logic as types”.
The second is Map
also returns a try object, which can be passed to another function which also conditionally executes. This is how try generically solves the repetitive error checking issue.
Go Try
It exists. There are two other abilities that every monad needs. One is confusingly called return
, which is a type converter that wraps a value in a monad. Go try has Succeed
for values and Fail
for errors:
func TryParseBool(s string) Try[bool] {
boolean, err := strconv.ParseBool(s);
if err != nil {
return Fail(err)
}
return Succeed(boolean)
}
result := TryParseBool("false")
These are the building blocks for functions which return try objects (“monadic functions”). But if all you’re doing is wrapping an existing function, try has a shortcut for that, called Lift
:
result := Lift("false", strconv.ParseBool)
The other ability every monad needs is a combinator called “bind”. It’s similar to map except it conditionally applies a monad to a monadic function (one that already returns a try object).
Here’s my truthiness program re-written with try:
package main
import (
"bufio"
"fmt"
m "github.com/dnmfarrell/try"
"os"
"strconv"
"strings"
)
func main() {
fmt.Print("Enter a truthy value: ")
r := readLine(bufio.NewReader(os.Stdin))
t := m.Bind(r, parseTruthy)
if t.Err != nil {
fmt.Println(t.Err.Error())
} else {
fmt.Println(t.Val)
}
}
func readLine(r *bufio.Reader) m.Try[string] {
return m.Map(Lift('\n', r.ReadString),
func(s string) string { return strings.TrimSuffix(s, "\n") })
}
func parseTruthy(s string) m.Try[string] {
return m.Map(Lift(s, strconv.ParseBool), func(b bool) string {
if b {
return "You entered a truthy value!"
}
return "You entered a falsey value!"
})
}
Compared to the original, it is eight lines of code shorter, with three fewer branches and six fewer variable assignments. The only time an error is checked for is at the end of the program, when the try object is unwrapped.
Critique
Bind, Map and Lift are simple functions that should be easy for the Go compiler to inline. However one quirk of Go is the compiler can’t optimize generic functions which take an interface:¹
Because of the way shape instantiation works for interfaces, instead of de-virtualizing, you’re adding another virtualization layer that involves a global hash table lookup for every method call. When dealing with Generics in a performance-sensitive context, use only pointers instead of interfaces.
Go generally has great tooling, and we take full advantage of that at work. For example we use errcheck to detect unhandled errors. Go try would need something similar that checks that Err
is tested before Val
is used. Consider:
a := Map(x, foo)
b := Map(a, bar)
c := Map(b, baz)
if c.Err != nil {
...
}
The only way a linter would know that all the errors have been handled is by understanding higher-order semantics. So Go Try would need to provide lint routines.
And what if you need to know at which stage the error occurred, so you can take additional action? That’s what makes the CopyFile
example from the error handling overview so difficult to refactor. I must confess I don’t have a good solution for that yet.
If Go supported generic methods, the code would be easier to read. Behold:
t := Bind(r, parseTruthy)
// becomes
t := r.Bind(parseTruthy)
Imagine beautiful chains of monadic computations sequenced together.
However implementing generic methods in Go is a hard problem because of their interaction with interfaces. Currently it has no known solution.²
Wrap
Despite the drawbacks, I still think Go try is a win for many use cases. For functions that only return a pointer value (like error), an Option type would be useful. In any case I hope I’ve embodied Rob Pike’s exhortation:
Use the language to simplify your error handling.
Notes
- Vincent Marti explains this in detail in Generics can make your Go code slower, planetscale.com.
- The Generics proposal has a good example problem.
Tags: error-handling monad try generics haskell