The Go nil interface
Predict the output of the following code snippet.
package main
import "fmt"
func main() {
err := work()
inspect(err)
}
type MyError struct{}
func (e MyError) Error() string {
return "oof"
}
func work() error {
var e *MyError
return e
}
func inspect(val any) {
fmt.Printf("Value: %#v\n", val)
fmt.Printf("Type: %T\n", val)
fmt.Printf("Nil check: %t\n", val == nil)
}
Down in work() we initialize variable e as the zero value of a pointer, making it nil. This value is returned and then inspected. So what is printed?
Value: (*main.MyError)(nil)
Type: *main.MyError
Nil check: false
Huh?
The error prints as nil, has its type captured (but is still nil, because it is right?), yet the equality check err == nil evaluated to false. What's up with that?
Variable e was indeed a pointer zero-value nil. However when returned through the error interface, it is converted (perhaps "wrapped" is more accurate) to a "typed nil".
Go's interface values are a composite of underlying value and its type. It is only because both value and type are stored that Go is able to offer developers type checks and value casting for interfaces.
// uses type inspection behind the scenes
errors.AsType[*MyError](err)
// more generically
err.(*MyError)
An interface value is only considered nil if both underlying value and type are nil. In the prior case, the "typed nil" interface we ended up with doesn't match such criteria - it stored a value and so the whole interface is treated as non-nil.
The earlier example was admittedly contrived and intentionally misleading. Anecdotally I don't even believe it's a common pitfall unless you're doing some advanced error/interface handling. It's nonetheless worth identifying this quirk is not specific to errors and is instead related to interfaces. The Go builtin error type is just an interface after all1.
Let's rework the prior example to make all this more evident, this time using an empty interface so any concrete type can fulfill it.
package main
import (
"fmt"
)
func main() {
val := work()
inspect(val)
}
// `any` is an alias for `interface{}` and is semantically equivalent
func work() any {
return "hello"
}
func inspect(val any) {
fmt.Printf("Value: %#v\n", val)
fmt.Printf("Type: %T\n", val)
fmt.Printf("Nil check: %t\n", val == nil)
}
Value: "hello"
Type: string
Nil check: false
Same inspect() output as before but it's more intuitive that we're not passing nil around. I think my original confusion when I stumbled upon this years ago was centered around nil-ability of pointers and other types (funcs, slices, maps), falsely assuming that property was upheld when wrapping in an interface value.
Return bare nil where possible for errors and other interfaces. Something like return nil does not encode any typing, hence the value will be fully treated as nil as you'd expect.
References
https://go.dev/blog/error-handling-and-go#the-error-type↩