DEV Community

Wesley Powell
Wesley Powell

Posted on • Edited on

Better Golang Error Handling

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.
}
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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.
missing cases in switch of type SampleError: ErrMyErrorOne, ErrMyErrorThree, ErrMyErrorTwo (exhaustive)

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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]()
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Top comments (3)

Collapse
 
codypotter profile image
Cody Potter • Edited

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 cool

Collapse
 
ccoveille profile image
Christophe Colombier

First, 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.

Collapse
 
delta456 profile image
Swastik Baranwal

Why don't you make a GitHub issue about your proposal? This might get accepted who knows.