It has been a while since I last posted about better golang error handling and I thought I would follow up with how this ended up in real code.
Final Error Design
The design is largely the same now, but with some naming tweaks and extra functions. I have also ironed out a lot of details regarding error propagation and added some more interesting concepts.
type Cause interface {
~uint
}
type Error[T Cause] struct {
Cause T
rootCause any
Message string
// Add as much extra detail as you wish!
}
func NewError[T Cause](ctx context.Context, cause T, message string, values ...any) Error[T] {
// ...
}
func FromError[T Cause, R Cause](cause T, origin Error[R]) Error[T] {
// ...
}
func Ok[T Cause]() Error[T] {
return Error[T]{}
}
// Error message.
//
// Satisfies the error interface.
func (self Error[T]) Error() string {
return self.String()
}
func (self Error[T]) IsOk() bool {
return self.Cause == 0
}
func (self Error[T]) IsErr() bool {
return self.Cause != 0
}
func (self Error[T]) String() string {
// ...
}
Note that the Error
is now simply the transport mechanism for the important piece, the Cause
. When handling the Error
, we check its Cause
to see what caused the function to fail.
When propagating errors, there is now the FromError()
option. This allows you to simply take an error from a function and return it as another error for your function. Note that this is just copying data around and there is no linked list behind the scenes. I did find it useful to keep the rootCause
as well as the current Cause
. That is so that we can log the first Cause
while still being able to react to different Cause
s as we pass the error up the call stack.
One of the nicest benefits of Error
over golang's error is that it requires zero heap allocation. As defined above, Error
is clearly superior to golang's error in every way. It does exactly what golang can do, but for a fraction of the CPU time and memory allocation. I encourage you to benchmark it yourself.
Note that you can add anything you wish to the Error
. It is simply there as metadata for your root cause. If you wish to make your errors heavier, but more descriptive, you could add codes, stack traces, metrics, logging, and more. There is a lot of power you can wield in this simple structure. This is simply a starting point for your project.
Error Usage
Here is an example of it being used.
type MyError uint
const (
MyErrorInternalFailure = MyError(iota + 1)
MyErrorNotFound
)
func getThing(ctx context.Context) Error[MyError] {
if true {
return NewError(ctx, MyErrorNotFound, "could not find the thing")
}
return errors.Ok[MyError]()
}
func main() {
ctx := context.Background()
switch getThingErr := getThing(ctx); getThingErr.Cause {
case MyErrorInternalFailure:
fmt.Println("failed finding the thing, internal failure: " + getThingErr.Error())
case MyErrorNotFound:
fmt.Println("failed finding the thing, not found: " + getThingErr.Error())
default:
// Found the thing!
}
}
When thinking about what error causes to create, think about what is important to test. Each failure case should have its own Cause
enum value on the function. For example, a function may return NotFound
, InternalFailure
, BadRequest
, etc. This means you can easily test for different cases. I find that keeping your testing in mind helps guide your coding decisions.
When a consumer handles a NotFound
case, it may not care and totally ignore it or it may combine it with another error that the function returns. Regrouping error causes becomes useful in cases where say your storage layer has a bunch of storage specific error cases, like maybe MySqlTransactionFailure, but the calling function just lumps them all into a generic InternalFailure
cause that it returns.
Another important point for this pattern is that you only ever have two return values: (<value>, <error>)
. Any "soft" error where your value may not exist should be contained inside your error cause enumeration. That eliminates the awkward case where you have no value, but no error either and you get the dreaded (in golang errors) (nil, nil)
, and you have to determine what that actually means. Keeping this you-have-it-or-not approach ensures that your error switch
s always handle every possible case and that your default
case is purely reserved for your success case and you are guaranteed a value.
A nice side effect of the switch pattern, is that you can often keep your variables with the scope of the switch to avoid mistakenly referencing variables and to keep your code cleaner.
Knowing that you are guaranteed a value and guaranteed those specific failure causes becomes powerful within large applications. When you enable linting (which you should already have!), any new error enum value will highlight your code where you need to handle this new case. When you remove a case, you will get compile errors showing where that case is no longer valid. This makes making business logic changes to large existing applications becomes much easier because your code highlights everything you need to change. It also forces you to think about the new case. Let's say you add a UserNotFound
case to a function deep inside your application. If you were using golang errors, then you may add this case and think you have it handled because everything is error
and checked with if err != nil
. But because each function is typed to its own possible error causes, you are now forced to handle this case in your entire application. This helps eliminate bugs before they even exist! I can attest that I have been pleasantly surprised at how many times this error pattern has saved me from making logical mistakes.
I encourage you to use this sample code and expand on it in your applications for your own needs. Check out the full example on the Go Playground!
Top comments (0)