Blog Cover

Custom errors in Go: providing meaningful errors and decoupling our code

Author profile image
Aitor Alonso

Aug 30, 2023

Updated Oct 28, 2023

13 min read

In Go, errors are a fundamental part of the language's design, and are represented by the error interface, which is defined as:

type error interface {
    Error() string
}

This simple interface requires any type that implements it to have an Error() method that returns a string description of the error. Therefore, when we invoke the New() function from the standard errors package, we are in fact instantiating a struct that implements that interface, as we can see in the source code of Go itself.

Let's see how a basic error looks like in Go:

package main

import (
    "errors"
    "fmt"
)

func main() {
    // err is a struct that implements the error interface
    err := errors.New("something went wrong")
    fmt.Println(err) // something went wrong
}

As you see, errors in Golang are extremely simple, and that's part of it strength. But now, the question: how should we handle them?

Errors handling in Go

If you read my previous article about concurrency and parallelism in Go, you may remember that I talked about a thing called The Go Proverbs. Those are a series of proverbs that define the philosophy of the language, the way things should be done in Go. There are two proverbs that are related to errors. Let's take a look at the first one:

Errors are values.

That's it. In Golang, as we saw previously, errors are just values (just a struct that implements the error interface), and they should be treated as such. You should forget what you know about other languages such as Java or Node.js, forget about try catch structures. In Golang there are not Exceptions. Exceptions are for exceptional things, and common errors in a software (e.g. a user doesn't exist) are not exceptional.

Therefore, we should avoid panicking (throwing) when we encounter an error, but instead, we should threat them as what they are: values. We should return them and let the caller decide what to do with it. Let's see an example:

package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    // We call a function that may return an error
    err := doSomethingThatMightFail()

    // We check if the error is not nil
    if err != nil {
        // We print the error and exit the program
        fmt.Println(err)
        os.Exit(1)
    }
    // If there is no error, we continue with the program
    fmt.Println("No errors so far")

    // Functions can return multiple values, so when a function
    // should return a value but might fail, it's common to return
    // the value and the error as second return value
    value, err := doSomethingElse()

    // Again, we have to check if the error is not nil
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }

    // If there is not error, we can be sure that we got the value we wanted
    fmt.Println("The value is", value)
}

Now, do you remember that I said that there are two proverbs related to errors? Well, the second one is:

Don't just check errors, handle them gracefully.

This means that we should not just check if there is an error and print it, as we just did, but instead, we should handle them. To do that, we usually need to know what the error is or what it means. Let's see what is the common and first approach to handle errors in Go, that you probably have seen before else where.

Sentinel errors

Sentinel errors are errors that are defined as variables, and are used to check if an error is of a certain type. For example, the io package defines the ErrShortWrite error, which is used to indicate that a write operation was not able to write all the data. We can also define or own sentinel errors, let's see an example:

package main

import (
    "errors"
    "fmt"
    "os"
)

var ErrSentinel = errors.New("sentinel error")

func main() {
    // We call the function that may return an error
    // of type ErrSentinel and others
    err := doSomething()

    // We check if the error is not nil
    if err != nil {
        // We check the type of the error
        if err == ErrSentinel {
            // In this case, we are okay with this error
            fmt.Println("Received sentinel error. Ignoring it")
        } else {
            // If the error is not ErrSentinel, we print it and exit the program
            fmt.Println(err)
            os.Exit(1)
        }
    }

    fmt.Println("Hello World!")
}

This is a common practice for error handling, and usually is the first approach that people take when they start using Go. However, this approach has some problems.

Sometimes, we need more from errors. We need to know where the error happened, or why. Also, imagine that someday, an external error returned by the standard or a third party library changes, and we are checking for that error by looking at its message. Our code will break, because the error is not the same anymore. To avoid that, we can wrap these external errors inside our custom domain error type. Let's see how to implement a good, decoupled custom errors package.

Custom errors package for domain errors

Here is an example of a custom errors package that I use in my projects, that I'll explain next:

package errors

import (
    stdErrors "errors"
    "fmt"
)

type ErrorCode string

const (
    InvalidUserID    ErrorCode = "INVALID_USER_ID"
    PersistenceError ErrorCode = "PERSISTENCE_ERROR"
    UserNotFound     ErrorCode = "USER_NOT_FOUND"
)

type domainError struct {
    // We define our domainError struct, which is composed of error
    error
    errorCode ErrorCode
}

func (e domainError) Error() string {
    return fmt.Sprintf("%s: %s", e.errorCode, e.error.Error())
}

func Unwrap(err error) error {
	if e, ok := err.(domainError); ok {
		return stdErrors.Unwrap(e.error)
	}

	return stdErrors.Unwrap(err)
}

func Code(err error) ErrorCode {
    if err == nil {
        return ""
    }

    if e, ok := err.(domainError); ok {
        return e.errorCode
    }

    return ""
}

func NewDomainError(errorCode ErrorCode, format string, args ...interface{}) error {
    return domainError{
        error:     fmt.Errorf(format, args...),
        errorCode: errorCode,
    }
}

func WrapIntoDomainError(err error, errorCode ErrorCode, msg string) error {
    return domainError{
        error:     fmt.Errorf("%s: [%w]", msg, err),
        errorCode: errorCode,
    }
}

What this does

It defines an ErrorCode type, which is just a string, and some constants that represent the different kind of errors that can happen in our domain. Those errors belong to our domain, the core of our application, its business logic. They are not errors from the standard library, or from a third party library. They are errors that we define, that for us they are meaningful, and that we can change if we need to.

Then, we define a struct called domainError, which is composed of error and includes an ErrorCode, providing some context. Therefore, this struct implements the error interface, so it can be returned as the old known error type, and be handled as a normal error if needed. Note that this struct is private (it is not exported, lowercase name), so it can only be used inside the errors package. This way we avoid coupling our code to the implementation details of the struct. Otherwise, we could end checking our domainError internal values and properties, thus handling it as a sentinel error, which is not what we want.

Instead, we should rely on the exported functions of the package. Let's take a look at them.

The first one is Error(). As domainError is composed of error, we could just call the Error() method of the error interface, or directly do not implement this at all and leave the standard errors package to handle it. However, we want to add some context to the error, so we overwrite it. This method returns a string that contains the error code and the error message.

The second one, Unwrap(), is used to get the original error if a given error is wrapping another error. This is useful if for whatever reason we want to get the original external error. We'll see an example later on, but might we easily understandable after I explain the WrapIntoDomainError() function.

There is another function, Code(), that takes an error and returns its ErrorCode. This is useful when we want to check if an error is of a certain type, but we don't want to check the error message, (it could change depending on the details of the error, and it's intended to give more context to an human and not to be handled programmatically). Instead, we can check the error code, which is a constant that will not change, and that unambiguously identifies what kind of error we have.

Finally, we have two functions that are used to create new errors. The first one, NewDomainError(), takes an ErrorCode and a message, and returns a new domainError. This is useful when we want to create a new error related with our domain from scratch. We'll see an example later on.

The second one, WrapIntoDomainError(), takes an error, an ErrorCode and a message, and returns a new domainError that wraps the given error. This is useful when we want to wrap an external error into a domainError, so we can add context to it, and also avoid coupling our code to external errors that don't belong to our domain, like those returned by the standard or third party libs. Here, the error is wrapped using the %w verb, so we can use the Unwrap() function defined before to get the original error if needed.

Obviously, we could add more info to our domainError struct, like a timestamp, or the file and line where the error happened, but for the sake of simplicity, I'm not going to do it here.

Let's see this code in action with a simple example of fetching a database:

package main

import (
    "database/sql"
    "fmt"
    "os"

    "myapp/errors"
    "myapp/users"
)

func connectToDatabase() (*sql.DB, error) {
    // We try to connect to the database
    db, err := sql.Open("<driver>", "<connectionString>")
    if err != nil {
        // If there is an error, we wrap it into a domain error and return it
        return nil, errors.WrapIntoDomainError(err, errors.PersistenceError, "error connecting to database")
    }
    return db, nil
}

func getUserNameFromID(db *sql.DB, id string) (string, error) {
    if id == "" {
        // If the id is empty, we return a new domain error
        return nil, errors.NewDomainError(errors.InvalidUserID, "user id cannot be empty")
    }

    // We query the database
    var name string
    err := db.QueryRow("SELECT name FROM users WHERE id = ?", id).Scan(&name)

    // And then handle the error
    if err != nil {
        if err == sql.ErrNoRows {
            return nil, errors.NewDomainError(errors.UserNotFound, "User with ID %s not found", id)
        }
        return nil, errors.WrapIntoDomainError(err, errors.PersistenceError, "error quering user by ID")
    }

    return name, nil
}

func main() {
    // We connect to the database
    db, err := connectToDatabase()
    if err != nil {
        // Directly printing the error will call the Error() method we defined in our errors package
        fmt.Println(err) // PERSISTENCE_ERROR: error connecting to database [sql: example error]
        fmt.Println(errors.Code(err)) // PERSISTENCE_ERROR
        fmt.Println(errors.Unwrap(err)) // sql: example error
        os.Exit(1)
    }

    // We get the user by ID
    id := "123"
    name, err := getUserNameFromID(db, id)
    if err != nil {
        // If there is an error, we check the error code
        if errors.Code(err) == errors.UserNotFound {
            // We handle the error gracefully
            fmt.Printf("There is not user with ID %s in the database\n", id)
            fmt.Println(err) // USER_NOT_FOUND: User with ID 123 not found
            fmt.Println(errors.Code(err)) // USER_NOT_FOUND
            os.Exit(0)
        } else {
            // If the error is not UserNotFound, we print it and exit the program
            fmt.Println(err) // PERSISTENCE_ERROR: error quering user by ID: [sql: example error]
            fmt.Println(errors.Code(err)) // PERSISTENCE_ERROR
            fmt.Println(errors.Unwrap(err)) // sql: example error
            os.Exit(1)
        }
    }

    // If there is no error, we print the user's name
    fmt.Printf("user's name: %s", name)
}

And that's it. We have a custom errors package that allows us to create custom context-rich errors that belong to our domain, and also wrap external errors to give them context and avoid coupling our code.

I hope you find it useful!


I hope my article has helped you, or at least, that you have enjoyed reading it. I do this for fun and I don't need money to keep the blog running. However, if you'd like to show your gratitude, you can pay for my next coffee(s) with a one-time donation of just $1.00. Thank you!