TL;DR Skip to the end to see the code
Motivation
I recently reviewed various reasons why the built-in golang error
is not great. In fact, if you Google "golang error handling" you will find many other blogs with various takes on how to navigate errors in golang. Even though the topic has seen lots of conversation, there really has not been much progress.
So I will throw out yet another blog post on golang error handling.
Start Fresh
First thing I will propose, though controversial, is to throw out everything there is about golang errors. Do not use error
. Do not use the "errors"
package. Just throw it all away (it was probably just giving you anxiety anyway). error
was more like a guideline anyway.
Now that we have no error handling we are open to a new world of possibilities. What does an error look like? What data should be contained in an error? Once created, how do we handle an error? Can we extend errors now? In this blog, I propose an error of the simplest possible terms so that it may be extended only as needed.
The Error
Type
type Error[T ~string] string
Yep. Let us just start with a new type called Error
. Note that we could also just reuse error
since it is not a keyword, but that gets slightly messy. This new Error
is simply just a string
. string
is a great starting point because errors messages are usually strings that are human readable.
Right away, we essentially have everything we need. An error could be "whoops"
and no error would be ""
(empty string). To make the usage around this a bit nicer to read, we can attach IsSome() bool
and IsNone() bool
. These two helper functions would just check if the value is ""
.
For example:
// Create an error
err := errors.New[string]("whoops")
// Handle the error.
if err.IsSome() {
// Do something useful with the error.
}
While this sufficiently covers creating errors and error handling, there is more that we can do. In fact, while these helper functions may remain on a possible real implementation, I would propose never using them. The reason? There is actually an even better approach that utilizes that generic T
on the Error
type.
Error Enums
One major issue with golang is the lack of enums. However, there are ways to make enums that linters will recognize. This usually follows in the form of:
const EnumType <some type>
const (
EnumTypeA = EnumType(<some value>)
EnumTypeB = EnumType(<some value>)
// ... and so on
)
Taking some inspiration from rust, it would be amazing if we could have something similar to match
that would force us to cover all enum cases. Fortunately, the next best thing is the exhaustive linter.
In the current version of golang error
, the only way to create a sentinel error is to do var ErrMyError = errors.New("whoops")
. But there is a problem with this: it is a variable, not constant. So this is not a real fake enum that linters recognize. It also does not make sense for these to be mutable globals.
The proposed new Error
type solves this issue by accepting a T ~string
. This means the type of the Error
itself is different depending on T
. T
is also constrained to ~string
which means two things: T
can be cast to string
, T
can be const. Note that even though T
is not stored as a value in the Error
type, we can still reference it because it is part of the type definition. This allows us to keep Error
as a simple string
while being able to cast it into T
. Taking some more inspiration from rust, we can add a function to Error
that does this:
func (self Error[T]) Into() T {
return T(self)
}
While this all feel like over-complicated code since it is all just syntax sugar around string
, it enables us to now have error handling like this:
switch err := StringError(0); err.Into() {
default:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
For a simple Error[string]
, default
catches an error if it exists and case errors.ErrNone
catches the "no error" case. However, if we have an error enum then that enum type is the T
and Into() T
returns the error of that type. This looks like:
_, err = EnumError(3)
switch err.Into() {
case ErrMyErrorOne:
fmt.Println(err)
case ErrMyErrorTwo:
fmt.Println(err)
case ErrMyErrorThree:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
Notice there is a case for each enum that was defined and there is no default
. Technically, to be thorough default
should be included but if you have to return an error of a certain type then that should not happen, but it could.
What about missing cases? If there is a missing case, the exhaustive linter will point it out and let you know that you did not include everything.
Extending Error
The golang error
will let you extend it and do whatever you want and any viable replacement should allow the same functionality. It very well may be that a string
is not sufficient for some use cases. In that case, we still need a way of extending Error
without having to change the pattern.
Let us assume that we desire a unique error code for tracking/debugging purposes that is assigned to each Error
. We can extend Error
by creating our new type MyError[T ~string] struct
and embedding Error
in it. MyError
now has access to the same functionality that Error
does while also having the benefit of storing an extra value for an error ID.
type MyError[T ~string] struct {
errors.Error[T]
// Other data defined here.
errId int
}
func New[T ~string](err T, errId int) MyError[T] {
return MyError[T]{
Error: errors.New(err),
errId: errId,
}
}
The usage is exactly the same except MyError[T]
is returned from a function instead of Error[T]
.
Advantages/Benefits
So why go through all this effort? Maybe you are satisfied with golang error
and need more convincing? Aside from what has already been stated in the sections above, there are more benefits gained from this proposed approach.
Performance
The proposed Error
is actually more performant than error
. This is because error
is an interface which means that literally every error ever created requires a memory allocation. In straight up benchmark comparisons, this is not all that much, but all of those allocations add up.
Here is my non-official benchmark comparison between creating a new golang error
and an Error
.
error
24.96 ns/op 16 B/op 1 allocs/op
Error
5.557 ns/op 0 B/op 0 allocs/op
A ~4.5x improvement is not too shabby even though we are talking about nanoseconds here.
Self Documenting Code
Documentation is always a struggle. The moment code changes, the documentation must be updated. However, using enums as types in Error
allows us to clearly see what errors a function returns. I have used some comments like this before but it just does not feel great:
// Foo does stuff
//
// Errors:
// * ErrFooFailure
func Foo() error { ... }
Instead, we can now have something like this:
type SampleError string
const (
ErrMyErrorOne = SampleError("error one")
ErrMyErrorTwo = SampleError("error two")
ErrMyErrorThree = SampleError("error three")
)
func EnumError(which int) (int, errors.Error[SampleError]) {
switch which {
case 1:
return 1, errors.New(ErrMyErrorOne)
case 2:
return 2, errors.New(ErrMyErrorTwo)
case 3:
return 3, errors.New(ErrMyErrorThree)
default:
return 0, errors.None[SampleError]()
}
}
This is clear, ties in with the compiler, and is easy to understand. The code simply documents itself.
"Accept interfaces, return structs"
This is a general rule in golang code, but error
completely breaks this since it is an interface. Once returned, there is no way to operate on the error except through helper functions like errors.As()
. Being required to call Unwrap()
, errors.As()
, etc means there is uncertainty, which usually leads to bugs. The uncertainty I am referring to is all of the issues mentioned in my previous post, such as error formatting. Since Error
is not an interface, we can operate on it with certainty.
No Wrapping
This touches on another small performance benefit. When "errors"
was abandoned for this proposal, we also dumped all of that error wrapping insanity. error
is used as a linked list. Since Error
is intended to be immutable and "one shot", there is no chain of errors that must be traversed to check if we got some type of error
. And with the recent release of go1.19
, if you happened to have a large set of errors then the proposed switch
error handling pattern would benefit from a switch jump table.
Next Steps
This is proposal is something I am still flushing out, but I feel like it shows real promise. In fact, in just writing this I changed a few things, but the core concept remains. I will keep iterating and experimenting my personal projects, though it may take some some convincing to use an iteration of this in actual projects.
TL;DR Just Show Me The Code!
error.go
package errors
import (
"fmt"
)
const ErrNone = ""
func None[T ~string]() Error[T] {
return Error[T]("")
}
func New[T ~string](format T, values ...any) Error[T] {
return newError(format, values...)
}
type Error[T ~string] string
func newError[T ~string](format T, values ...any) Error[T] {
var err string
if len(values) != 0 {
// Do not call fmt.Sprintf() if not necessary.
// Major performance improvement.
// Only necessary if there are any values.
err = fmt.Sprintf(string(format), values...)
} else {
err = string(format)
}
return Error[T](err)
}
func (self Error[T]) IsNone() bool {
return self == ""
}
func (self Error[T]) IsSome() bool {
return !self.IsNone()
}
func (self Error[T]) Into() T {
return T(self)
}
error_example_test.go
package errors_test
import (
"fmt"
"experimentation/errors"
)
func StringError(which int) errors.Error[string] {
switch which {
case 1:
return errors.New("error: %s", "whoops")
default:
return errors.None[string]()
}
}
func ExampleStringError() {
switch err := StringError(0); err.Into() {
default:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
switch err := StringError(1); err.Into() {
default:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
// Output:
// no error
// error: whoops
}
type SampleError string
const (
ErrMyErrorOne = SampleError("error one")
ErrMyErrorTwo = SampleError("error two")
ErrMyErrorThree = SampleError("error three")
)
func EnumError(which int) (int, errors.Error[SampleError]) {
switch which {
case 1:
return 1, errors.New(ErrMyErrorOne)
case 2:
return 2, errors.New(ErrMyErrorTwo)
case 3:
return 3, errors.New(ErrMyErrorThree)
default:
return 0, errors.None[SampleError]()
}
}
func ExampleEnumError() {
_, err := EnumError(0)
switch err.Into() {
default:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
_, err = EnumError(3)
switch err.Into() {
case ErrMyErrorOne:
fmt.Println(err)
case ErrMyErrorTwo:
fmt.Println(err)
case ErrMyErrorThree:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
// Output:
// no error
// error three
}
type MyError[T ~string] struct {
errors.Error[T]
// Other data defined here.
errId int
}
func New[T ~string](err T, errId int) MyError[T] {
return MyError[T]{
Error: errors.New(err),
errId: errId,
}
}
func (self MyError[T]) String() string {
return fmt.Sprintf("error: %s, id: %d", self.Error, self.errId)
}
func MyErrorFn() MyError[string] {
return New("whoops", 123)
}
func ExampleMyError() {
switch err := MyErrorFn(); err.Into() {
default:
fmt.Println(err)
case errors.ErrNone:
fmt.Println("no error")
}
// Output:
// error: whoops, id: 123
}
Top comments (3)
Honestly the Self Documenting Code section caught my eye the most here. I'll play around with this. Its a little verbose, but the gains in exhaustive type checking make it way more powerful than standard go
error
. Very coolFirst, I thought what the hell, is he trying to do... So complex.
Then I read again, and see you were using generics and mentioned jump table optimization in golang 1.19.
So, I thought, he has some experience.
Then I read your previous article about errors. ๐
I'm still unsure about your suggestion, but at least you have my attention ๐
I will experiment when I will be back on front of my computer in few weeks.
Why don't you make a GitHub issue about your proposal? This might get accepted who knows.