Follow Up: Establishing A Better Pattern

wspowell

Wesley Powell

Posted on September 9, 2022

Follow Up: Establishing A Better Pattern

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

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

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 with switch

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

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

Using our Context example, we could use this as:

errors.NewMessage(Canceled, "context was canceled by user")
Enter fullscreen mode Exit fullscreen mode

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

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.

💖 💪 🙅 🚩
wspowell
Wesley Powell

Posted on September 9, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related