Follow Up: Establishing A Better Pattern
Tags: go, design, errors, tutorial
Published: 2022-09-09 00:24:31

## _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](https://golangci-lint.run/usage/linters/#exhaustive) with `switch`_
* _Implement [Stringer](https://cs.opensource.google/go/go/+/refs/tags/go1.19.1:src/fmt/print.go;l=62) 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`](https://pkg.go.dev/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](https://golangci-lint.run/) with the [exhaustive linter](https://golangci-lint.run/usage/linters/#exhaustive) 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`](https://doc.rust-lang.org/rust-by-example/flow_control/match/destructuring/destructure_enum.html) 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](https://cs.opensource.google/go/go/+/refs/tags/go1.19.1:src/fmt/print.go;l=654) 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](https://stackoverflow.com/a/62291060). 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.