Skip to main content

Command Palette

Search for a command to run...

Golang and nil-interfacing gotchas

Best Practices for Handling Nil Interfaces when using Golang

Published
5 min read

I have been using Golang extensively in my role as an engineer at Couchbase for over three years.In this blog, I’ll cover a big big gotcha when using golang and interfaces which can lead to nasty bugs in prod!

📜 Interfaces in Golang

Golang supports defining interfaces and using them in code. Here goes a sample interface -

type Blog interface {
    Read() string
    Comment(string)
}

type blogImpl struct {
    content string
    comments []string
}

func (bi *blogImpl) Read() string { return bi.content }

func (bi *blogImpl) Comment(newComment string) {
    bi.comments = append(bi.comments, newComment)
}

func run() {
    var article Blog = &blogImpl{}
}

And just like that, blogImpl struct implements the interface Blog; No explicit declaration needed. At compile time, go compiler will determine if blogImpl struct has all the methods (with same API signature) mentioned in the Blog interface else fail compilation.

So what is the gotcha here?

🌑 Come to the Dark Side!

Let’s understand the gotcha of the article with an example. The highly used interface in golang is the error interface. Used every where. We perform assertions to make sure if it is nil or not every single day. Handling error makes our code very robust. Let’s look at the sample code below -

// Find the same code runnable at - https://go.dev/play/p/Sg_kF08tUTl
package main

import (
    "fmt"
    "log"
    "strings"
)

type IntError int

func (ie IntError) Error() string {
    return fmt.Sprintf("%d", ie)
}

func newIntError(str string) *IntError {
    if strings.Contains(str, "error") {
        var e IntError = -1
        return &e
    }
    return nil
}

func cleanError(str string) error {
    return newIntError(str)
}

func main() {
    err := cleanError("some error")
    if err != nil {
        log.Printf("got (1)err - %v", err)
    }
    err = cleanError("nothing here")
    if err != nil {
        log.Printf("got (2)err - %v", err)
    }
}

What should be the expected output of the following program? Something like -

2009/11/10 23:00:00 got (1)err - -1

Program exited.

Right?? RIGHT?? NOOOO! IT ISN’T!! The output is -

2009/11/10 23:00:00 got (1)err - -1
2009/11/10 23:00:00 got (2)err - <nil>

Program exited.

But….. how?? Naaa hoyeee!! We have a nil check in place and still we see the output for 2nd error but nil as the output in log. HOW???

🔍 The Internals of Interface

How golang represents interface can be found at - https://go.dev/src/internal/abi/iface.go?m=text

// NonEmptyInterface describes the layout of an interface that contains any methods.
type NonEmptyInterface struct {
    ITab *ITab
    Data unsafe.Pointer
}

ITab → type of the implementation; Data → pointer to object of the ITab type

In our example, ITab is IntError and Data would be the pointer to, wait for it, NIL! Because of this representation, we do have a non-nil error (points to valid struct object) but it’s underlying value/data is nil. But what caused this issue in our program? What made the error be an object and it’s underlying value nil? The problem is clearError function -

func cleanError(str string) error {
    return newIntError(str)
}

The return type for newIntError is IntError. Since IntError implements error interface, its a valid return value. This means, the error we are returning is always an object &NonEmptyInterface{ITab: *IntError, Data: newIntError(str)}; So when newIntError returns nil, it gets stored in Data as nil and the interface object getting returned will not be nil.

If we change the main func to below, we can see that the cleanError returns the type and its Data is nil -

func main() {
    err := cleanError("some error")
    if err != nil {
        log.Printf("got (1)err - (%T) %v", err, err)
    }
    err = cleanError("nothing here")
    if err != nil {
        log.Printf("got (2)err - (%T) %v", err, err)
    }
}

/*
2009/11/10 23:00:00 got (1)err - (*main.IntError) -1
2009/11/10 23:00:00 got (2)err - (*main.IntError) <nil>

Program exited.
*/

🛠️ How to fix?

Now that we know what is wrong, we can fix it in multiple ways -

✨ Fix 1: Extract the underlying type

Since we know that the cleanError is supposed to be returning (*IntError), we can match for type of the error and then take actions -

err = cleanError("nothing here")
if err != nil {
    ie, ok := err.(*IntError)
    if ok && ie != nil {
        log.Printf("got (2)err - %v", err, err)
    } else {
        log.Printf("got (3)err - (%T) %v", err, err)
    }
}

This does the charm as the underlying Data is nil, we will see no logs after the (1) error log. The problem remains that in a real world scenario, we could be creating many implementations for a given interface. Matching all of them in a particular case will become extremely difficult. So lets see if we have any more fixes.

✨ Fix 2: Check return values in intermediate functions

Instead of directly returning the values from newIntError, we can check the result in cleanError and return the appropriate type from there -

func cleanError(str string) error {
    ie := newIntError(str)
    if ie != nil {
        return ie
    }
    return nil
}

This ensures we return an error object only if the newIntError returns a non-nil error. But this adds more work on all layers where we can call such methods. Factory patterns, where interfaces get used widely, will suffer from a lot of conditions to ensure every type has been accounted for. Can we do better? YES!

✨ Fix 3: Always return Interfaces

Instead of returning concrete types, we can always choose to return interfaces. Even if we are creating errors for IntError category, the signature should only capture the interface -

func newIntError(str string) error {
    if strings.Contains(str, "error") {
        var e IntError = -1
        return &e
    }
    return nil
}

This way, the cleanError return value never gets a type if the return value is nil from newIntError. This means overall simplification on all functions calling newIntError or cleanError.

💡 Conclusion

Understanding how Golang handles interfaces and nil values is crucial for writing robust and error-free code. Interfaces are powerful tools in golang. Use them wisely. With great interfaces comes great nil check responsibilities 🕸️.