Takeaways
- Errors as uint64 for better performance
- Use enums to define error cases
- By convention, 0 is "OK" or "no error"
- Use the exhaustive linter with
switch
- Implement Stringer on enums for human readable error messages
- Use generic
T ~uint64
to type custom structs to errors
I have been racking my brain trying to figure out a better golang error handling pattern. In my previous posts, I alluded to a potential solution but after some experimentation it was not sufficient. After revisiting and coming back to this topic a few more times, I think I hit on something. The best part is this requires no external dependencies and does not require wholesale conversions to this new pattern.
Defining an Error
Defining what an error is actually gets us to the root of the issue. Usually in golang, an error is a string or a struct that contains a string value. But when something fails in a function, sometimes there really is no reason to return a full human readable string. It can be just as good to return an integer constant that identifies the error.
Performance
Why an integer, though? If we examine error
, we can see that it is an interface and therefore using it will cause the value to escape to the heap. That means all errors
are heap allocations. While golang utilizes garbage collection, too many heap allocations can cause performance issues across your application. Consider a situation where an application is running nominally and suddenly an influx of errors occur. You would not want a spike in error value allocations to cause an unexpected CPU spike that could potentially ripple through a system. Defining errors as uint64
solves that problem. That will be heap allocation free.
- Takeaway: Errors as uint64 for better performance
Case study: context.Context
Let us take Err() error
from context.Context
as an example. In the function comments, it essentially states that it returns one of:
- No error (
nil
) - var Canceled = errors.New("context canceled")
- var DeadlineExceeded error = deadlineExceededError{}
We can already take that description and use it directly in our first new error pattern example.
type ContextError uint64
const (
Ok ContextError = iota
Canceled
DeadlineExceeded
)
func (c Context) Err() ContextError {
// Could return this error denoting the Context was canceled.
return Canceled
// Or this error denoting the Context deadline passed.
return DeadlineExceeded
// Or there is no error.
return Ok
}
Right away we can see that provides self-documenting code. We know Err()
will return a ContextError
and that it is defined as one of two values: Ok
, Canceled
, DeadlineExceeded
. This is a huge win already.
- Takeaway: Use enums to define error cases
How would we use this? Before, we would use if err != nil
or possibly if errors.Is(err, context.Canceled)
. Instead, we can stick to one single pattern using switch
.
switch err := ctx.Err(); err {
case context.Canceled:
// Handle the case when the Context is canceled.
case context.DeadlineExceeded:
// Handle the case when the Context deadline has passed.
case context.Ok:
// There is no error!
}
Let us take a look at what happens here. The switch
here acts like a better if-else
. We invoke Err()
and get an err
value and then we switch on err
to find the correct case. If there is no error then we just return Ok
(which is just 0). Since the type of err
is ContextError
, we know exactly what errors to handle!
- Takeaway: By convention, 0 is "OK" or "no error"
In fact, if you are using the highly recommended linting tool golangci-lint with the exhaustive linter enabled then your IDE will actually warn you when you are missing an error case. And if an error value is ever removed, then you will get a compile error telling you that case is no longer valid. The usage of this pattern is heavily influenced by rust's match
operator.
- Takeaway: Use the
exhaustive
linter withswitch
Static Error Messages
Sometimes we want to log our errors and uint64
will not cut it. Especially since iota
starts over for each const
block. Fortunately, golang has already solved this for us as well! Whenever you invoke a Print
on a value via fmt.Println()
, the log
package, or other method, it will attempt to print it based on reflection. There is actually a Stringer
interface that golang will check for and use that if the value implements it. This interface looks like this:
// Stringer is implemented by any value that has a String method,
// which defines the “native” format for that value.
// The String method is used to print values passed as an operand
// to any format that accepts a string or to an unformatted printer
// such as Print.
type Stringer interface {
String() string
}
Using this on an enum is already an established pattern. We can easily attach this to our ContextError
:
func (self ContextError) String() string {
return [...]string{
"ok",
"context canceled",
"context deadline exceeded",
}[self]
}
- Takeaway: Implement Stringer on enums for human readable error messages
Custom Errors
Sometimes we do want to return some context with our errors. In those instances, a uint64
will not cut it. For those cases we can simply create custom error structs that suite our needs. For example, if all we need is a dynamic string to accompany our uint64
error for log printing later, we can do something like this:
// errors/message.go
type Message[T ~uint64] struct {
Error T
Message string
}
func NewMessage[T ~uint64](err T, message string) Message[T] {
return Message[T]{
Error: err,
Message: message,
}
}
Using our Context example, we could use this as:
errors.NewMessage(Canceled, "context was canceled by user")
There is a bit going on here. First we have Message[T ~uint64]
. This tells us that a Message
requires some type whose underlying value is uint64
(~
indicates underlying value). Since Canceled
is of type ContextError uint64
, that satisfies this type constraint. So what does that give us? Well now we know Error
is of type T
so when we switch
on Message.Error
, this functions exactly like our simpler enum example from above. But now that we have this new struct, we can also pull out Message.Message
and log it.
- Takeaway: Use generic
T ~uint64
to type structs to errors
If we want a "no error" case for Message
then we could make a helper function:
func Ok[T ~uint64]() Message[T] {
return Message[T]{}
}
Since the default value of uint64
is 0 and our convention is that 0 is equivalent to "no error" then golang solves this perfectly for us.
What about error
and the errors
package?
We simply do not need them anymore. In fact, you could just shadow error
in all of your packages: type error uint64
, but I would not actually recommend that. It is just more evidence that standard error handling in golang is more like a guideline than an actual rule.
Conclusion
Admittedly, this is not perfect. Returning "no error" is particularly funky. However, what this shows is that we do not need any extra packages to handle fancy stuff in order to get into better golang error handling. Golang already provides everything we need, we just have to define a new convention using those pieces and improve our code.
Top comments (1)
Awesome read, love it!
error
is akin of parlay then 😂