Go Error Handling: Sentinel vs Custom Types
A practical guide on when to use sentinel errors versus custom error types in Go.
Introduction
The feeling of starting a project from an idea and ending up building a working application is one of the most rewarding experiences in software development. Another rewarding experience comes from understanding how different parts of a complex system interact with each other. This understanding helps us communicate through code in a way that others can easily comprehend. As projects naturally grow in complexity, we can continue adding functionality while keeping ambiguity and unnecessary complexity at bay. Error handling is a crucial part of this process.
In Go, errors are values that represent an abnormal (yet expected) condition in the program execution. They are used to signal that something went wrong and provide additional information about what happened.
The String Comparison Trap
Before we dive into sentinel and custom types, let’s first clarify why comparing errors to its string representation is not a good idea.
In Go, any value that implements the error
interface can be passed around as an error. The error
interface is defined as follows:
1
2
3
type error interface {
Error() string
}
The string returned by the Error()
is a way to give more information about the error. It is not meant to be used for comparison since:
- Error messages may change.
- Messages can be localized.
- Different errors might share the same message.
- String comparisons are brittle and prone to typos.
- Performance implications of string comparisons.
The only motivation of the error message is to provide a human-readable description of the error, and not to be used for programmatic decisions.
1
2
3
4
5
6
7
8
9
10
11
12
13
package main
import (
"errors"
"fmt"
)
func main() {
// Don't do this!
if err.Error() == "something went wrong" {
// handle error ...
}
}
Sentinel Errors
Sentinel errors are predefined errors that are used to compare against the error returned by a function. They are usually defined as package-level variables, and since Go 1.13, are compared using the errors.Is
function.
If you’re a Go developer, chances are you more than familiar with this approach:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main
import (
"errors"
"fmt"
)
// This is a sentinel error.
var errSomethingWentWrong = errors.New("something went wrong")
func main() {
err := doSomething()
if errors.Is(err, errSomethingWentWrong) {
fmt.Println("Error occurred")
}
}
The good bits about sentinel errors are:
- Simplicity: They are easy to understand and use.
- Comparison: Can be compared using
errors.Is
, which is the recommended way to compare errors in Go - especially when dealing with wrapped errors. - Package API: When organized properly, they provide users a clear picture about the errors a package might return and that can be reasoned about.
Sentinel errors are a great choice when you are using the errors withing the scope of a single package - like using them in tests to assert the error returned by a function; or when you have full control of the code using the error. For example, when sharing sentinel errors between packages co-existing in the same internal directory.
We shouldn’t have to programming defensively against ourselves.
But it’s important to understand the limitations of sentinel errors when using them in public APIs.
Let’s see an example that illustrates this.
Let’s say we have a package that exposes a function that returns a sentinel error:
1
2
3
4
5
6
7
8
package sentinel
import "errors"
// ErrSomethingWentWrong is a sentinel error.
var ErrSomethingWentWrong = errors.New("something went wrong")
func DoSomething() error { return ErrSomethingWentWrong }
Now, let’s have another package that uses the sentinel
package:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main
import (
"errors"
"fmt"
"io"
"github.com/alesr/goerror/sentinel"
)
func main() {
err := sentinel.DoSomething()
if errors.Is(err, sentinel.ErrSomethingWentWrong) {
fmt.Println("Error occurred")
}
}
So far, so good. But what if we want to modify the sentinel error in the main
package?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main
import (
"errors"
"fmt"
)
var ErrFoo = errors.New("foo err")
func do() error { return ErrFoo }
func main() {
if err := do(); err != nil {
fmt.Println(err.Error())
}
}
// Note that this could be implemented in any package with access to the exported ErrFoo.
type bang struct{}
func (e bang) Error() string {
panic("kaboom")
return ""
}
func init() { ErrFoo = bang{} }
In this case, we have an init
function that alters the content of ErrFoo
. This is a contrived example, but it illustrates the point that sentinel errors can be modified by any package that has access to them. This can lead to unexpected behavior and bugs that are hard to track down.
Sentinel errors are not suitable for use in public APIs or when you need to ensure that the error is not modified by other packages. In such cases, custom error types are a better choice.
The standard library uses sentinel errors in some cases, like io.EOF
and io.ErrUnexpectedEOF
. I believe that the use of sentinel errors in the standard library is an exception rather than the rule, and that much was learned since the standard library was created. And this should also apply for the writers of the early Go packages.
Custom Error Types
Custom error types are user-defined types that implement the error
interface. They are used to create errors that are unique to your application or package. Custom error types are useful when you need to provide more context about the error or when you want to ensure that the error is not modified by other packages.
Let’s see an example of a custom error type:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main
import (
"errors"
"fmt"
)
// customError is the baseline for all our custom errors.
type customError struct{ reason string }
// Error returns the error message implenting the error interface.
func (e customError) Error() string { return e.reason }
// DoError is a custom error type that embeds customError.
// Therefor, it implements the error interface.
type DoError struct{ customError }
// ErrDo is a custom error instance.
var errDo error = DoError{customError{"could not do something"}}
func do() error { return errDo }
func main() {
if err := do(); err != nil {
if errors.Is(err, errDo) {
fmt.Println("The error is:", err.Error())
}
var e DoError
if errors.As(err, &e) {
fmt.Println("Again, the error is:", err.Error())
fmt.Println("The reason is also:", e.reason)
}
}
}
In this example, we define a custom error type DoError
that embeds the customError
type. This allows us to have access to the error
interface in DoError
by implementing the Error()
method for customError
.
A big advantage of custom error types is that the variable that holds the error is private to the package that defines it. This means that other packages cannot modify the error, which is important when you want to ensure that the error is not altered by other packages, even accidentally.
Another advantage of custom error types is that they can provide more context about the error. In the example above, we have a reason
field in the customError
type that provides more information about the error. Extending the customError
type with more fields is straighfoward and all the errors that embed it will have access to the new fields.
Honorable Mention: Error Wrapping
A common need in real applications is to add context to errors as they bubble up through the call stack. Also since Go 1.13, we can use the %w
verb with fmt.Errorf
to wrap errors while preserving their original type.
This is particularly useful when working with both sentinel errors and custom error types.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main
import (
"database/sql"
"errors"
"fmt"
)
func queryUser(id int) error {
// Simulating a database query
err := sql.ErrNoRows // pretend this came from a real query
if err != nil {
// Wrap the error with additional context
return fmt.Errorf("failed to query user %d: %w", id, err)
}
return nil
}
func handleRequest() error {
if err := queryUser(123); err != nil {
// We can still check the original error
if errors.Is(err, sql.ErrNoRows) {
// Handle specifically no rows case
return fmt.Errorf("user not found: %w", err)
}
// Or just add more context and pass it up
return fmt.Errorf("request failed: %w", err)
}
return nil
}
func main() {
err := handleRequest()
if err != nil {
// The error message will contain the full chain:
// "request failed: user not found: failed to query user 123: sql: no rows in result set"
fmt.Println(err)
// And we can still check for the original error
if errors.Is(err, sql.ErrNoRows) {
fmt.Println("No user found!")
}
}
}
Basically, avoid comparing errors to their string representation altogether. And be consistent and informative on adding context (don’t mistake with context.Context
) to errors as they bubble up through the call stack. This can save you a lot of time when debugging issues in production (trust me).
Conclusion
Error handling in Go reflects the language’s emphasis on explicitness and simplicity, and on enabling developers to build reliable applications. When choosing between sentinel errors and custom error types, consider the scope and stability requirements of your API:
For package-internal error handling and testing, sentinel errors offer simplicity and clarity For public APIs and complex error scenarios, custom error types provide better encapsulation and extensibility.
Regardless of the approach chosen, always favor the errors.Is
and errors.As
functions over string comparisons, and design your error handling to be both maintainable and informative. Good error handling isn’t just about catching failures - it’s about making your code more reliable and easier to reason about. I hope this post helps you understand the trade-offs between sentinel errors and custom error types, and guides you in choosing the right approach for your project.