Custom errors in Go: providing meaningful errors and decoupling our code
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!