Error Handling in GO – Part 3

Error Handling and Abstracting in GO - Part 3

Chapter 3 – Writing a Custom Error Type

In the first chapter, we learned the basics of errors in go. How to create an error with a particular message. Then we looked into exposing different types of errors in our packages so that consumers of our code can handle them differently, if appropriate.

In the second chapter of our series, we learned how to enhance our error messages to provide better user information. We learned how to add additional information to our error messages while retaining the ability to distinguish between different error cases. The additional information can be used by whoever receives the error message to hopefully resolve their issue. In our example of a configuration provider, we return a syntax error if the provided configuration is in an invalid syntax.

We then learned that we can wrap errors with additional information, such as the line number of the syntax error. This additional information is not easily machine-readable, and we cannot use it to refine our automated error handling, such as retry logic. We can only differentiate based on the error we wrapped with the additional information.

In this part, you will learn how to create your own error types, which can have any behavior you may find helpful. We will use this to resolve an issue we found in the last chapter: how to decorate an error that was returned from a library our application uses, decide how to handle it based on our sentinel errors, and yet, still have access to the original error. Once you know how to implement custom error types, we will show you some examples where this may be useful.

To learn how to create a custom error, we first need to understand what errors in go are. As you might already know, errors are values instead of exceptions, like in other languages. This allows us to return them instead of throwing them and modifying them, or providing our own error types, as you will learn in this chapter.

More specifically, errors in go are not just of any value. When you return an error in go, the type will be `error`. `error` is not as you might expect a concrete value, but rather an interface. Interfaces can be implemented in multiple types, and each type can have its own behavior. If you are unfamiliar with interfaces in go, take a break and look at https://go.dev/tour/methods/9 to familiarize yourself with the concept.

How do I implement my own types that satisfy the error interface?

In Go, any type that implements the methods of an interface will satisfy the interface. This is different from other languages, in which you must declare that you want to implement the interface in the type (or class) you’re implementing.

In the case of the error interface, it requires a singular method Error() string to be present, which returns an error message string.

This allows us to create error types on any type we define.

To use our syntax error as an example, we can define an ErrSyntax struct that contains the line and character of the syntax violation.

// configprovider/errors.go
package configprovider

// Will be returned if the configuration contains invalid syntax
type ErrSyntax struct {
  Line int
  Character int
}

func (e ErrSyntax) Error() string {
  return fmt.Sprintf(“syntax error in line %d character %d”, e.Line, e.Character)
}

As you can see in the code block, we implement the error interface by providing an `Error` method on our `ErrSyntax` struct that returns a string. This means our ErrSyntax structs can now be passed or returned as type `error`.

We can also implement our own `Is`, `Unwrap`, and `As` methods to specify the behavior of `errors.Is`, `errors.Unwrap`, and `errors.As`.

This allows us to specify an error type that perfectly fits our needs. Let’s look at an example scenario:

In the last part of this series, we discovered that sometimes we want to return an error at a high level of abstraction, but our application encountered an error calling a library. The last post used the example of a parser library; in this part, we will use a database driver library as an example.

We want to return a sentinel error on the level of our application domain, such as “could not load configuration from database.” Unfortunately, this message does not contain much information that could be useful in debugging this error. What could have happened here? Did the network time out? Were we unable to authenticate to the database? Was the database query malformed? Did the data not exist in the database?

All of these scenarios may have caused this error. To debug this, it would be beneficial to access the original error that our database library returned.

In the last part, we discussed wrapping the sentinel error with a serialized version of the underlying database driver error. 

if err := dbAction(); err != nil {
  return fmt.Errorf(“%w: %v”, ErrCannotLoad, err)
}

But let’s imagine we want to only show the error message to the user and have the additional information in an expanding box underneath, so they do not get overwhelmed if they do not have a technical background. We can get to the Sentinel value by calling Unwrap() on the returned error. But how do we get to the implementation-specific error to show it in the expanding text field? Here we reach the limit of what the error helpers in the standard library can do. Before we start thinking about parsing the error string, we should implement our own error type to handle this problem.

What do we need? We want to have an error that contains a sentinel value describing the semantics of the failure and still contains the original, implementation-specific error.

In order to do so, we could use a struct like this:

type Err struct {
  Sentinel error
  ImplementationSpecific error
}

In order to make this struct satisfy the `error` interface, we have to implement the `Error`method.

func (e *Err) Error() string {
  return fmt.Sprintf(“%s: %s”, e.Sentinel, e.ImplementationSpecific)
}

Let’s now see how we can apply the newly defined error type to solve our problem of the expanding error dropdown for more information.

If we directly return the error as our `Err` struct type, we can access the fields to get both the Sentinel error message and the Implementation-specific error by accessing them on the return value.

type DbProvider struct {//…}
func (DbProvider) Get() (*config, Err) { //…}

func main() {
  cfg, err := DbProvider{}.Get()
  if err != Err{} {
    switch err.Sentinel {
      case configprovider.ErrSyntax:
        // display to user
      default:
        os.Exit(1)
    }
  }

  // use config
}

However, this may not be the case. Your configprovider may be passed to your code as an interface value. If the interface uses the generic `error` as a return value, we cannot access these fields. To make decisions on the sentinel error type, even if our error is of type `error`, we need to implement the `Is` function, which allows us to look into the error for comparison like we did with wrapped errors in the last part.

// Example: errors.Is(Err{SyntaxErr}, SyntaxErr) // true
func (e *Err) Is(err error) bool {
  return errors.Is(e.Sentinel, err)
}

Unwrapping until we reach our Err struct using errors.As

Going back to our original task, how can we access the sentinel error and implementation-specific error (which we want to show in the expand textbox) now? 

For that we can use errors.As:

`errors.As` takes two arguments, the error and a pointer to a variable. If the error is of the same type as the variable or wraps an error, that is errors.As writes it to that variable and returns true. If the error you encountered cannot be converted to the type you tried to convert it to, it will return false.

This is an advantage over normal type casting via `err.(Err)`.  This conversion will not work if the error has been wrapped, for example, via `fmt.Errorf(“%w”, err)`.

Using `errors.As`, we can get the Sentinel field and display it to the user by converting our error value back from the `error` interface to our struct. 

Usage example

Putting our custom errors into context, our application and database library. To recap: we wrote a configuration provider that talks to a database to fetch some configuration and validate its syntax. If an error occurs, we want to display a short error message to the user, and an expanding box underneath, which shows what happened behind the curtains.

Using custom error types, it could look like this:

// PrintDocument sends the document to the printer
func PrintDocument(document Document, cfg configuration.Provider, printer Printer) error {
  c, err := cfg.Get(printer)
  // check for sentinel
  if errors.Is(err, config.ErrTimeout){
  // retry later
  }
  // check for custom error type
  var e ErrSyntax
  if errors.As(err, &e) {
    displaySyntaxHint(e.Line, e.Column)
  }

  var boxErr Err
  if errors.As(err, &boxErr) {
    displayExpandingBox(boxErr.Sentinel, boxErr.ImplementationError)
  }
  // check for everything else
  if err != nil {
    fmt.Println(“unexpected error”, err)
    os.Exit(1)
  }
  sendToPrinter(document, c)
}


package configuration

// Provider loads the configuration from a database
type Provider struct {}
func (Provider) Get(printer Printer) (Configuration, error){
  config, err := talkToDB(printer.Kind)
  if err != nil {
    return nil, Err{ErrCannotLoad, err}
  }
  err = validate(config)
  if err != nil{
    return nil, Err{ErrInvalidConfig, err}
  }
  return config, nil
}

As discussed before, the advantage here is that `errors.As` will always unwrap err until the first Err struct and no further. This allows us to get the highest level sentinel (and additional information it may be wrapped with), even if the error is nested multiple times.

Our custom error type allows for arbitrary wrapping inside the struct to add detail to our Sentinel errors. It also allows us to wrap the struct for additional context.

When Choosing Which Approach

Now that our series has ended, we look back and remember what error-handling methods we learned about and when we should use them.

Opaque errors

Opaque errors were our starting point. We simply generated errors from a string and returned them. This can be done using `errors.New` or `fmt.Errorf`. Because these errors are just a string, we cannot handle them properly. Because of that they are best suited for:

  • Rapid prototyping
  • Simple applications
  • No automatic responses to errors needed
Pure sentinel errors

Sentinel errors allow us to handle different types of errors. To make it easy for callers of the package, we export the errors we will return as public variables. That way, callers can compare the received errors to the known exported errors. The downside of course is that the error messages can only be static, and not contain any dynamic information. You can use this if:

  • If you provide a library with a stable API
  • If API users do not require more details on your errors
Wrapped sentinel errors

Wrapped sentinel errors allow us to add dynamic information to our sentinel errors using `fmt.Errorf`. However, there is no way to get back at the dynamic information. If there are multiple layers of wrapping, getting back to the sentinel error contained may become complicated.

Use this if:

  • You want users to decide on error semantics
  • You want to provide additional information on how the error occurred
Custom Error type

The most powerful approach, although it requires a little more work. We define our own type, which implements the `error` interface. Allows us to define how the error is turned into a string and what data is contained. Use this if:

  • You want to retain the entire original error of the implementation while simplifying the error message
  • You have multiple layers of abstractions and want to bubble up your error throughout them without losing abstraction
  • You need wrapped sentinel errors providing details of why the sentinel error was returned in addition to the causing error
  • You only want to unwrap your error to a certain depth. This may be useful for displaying error messages.

Leave a Reply

Your email address will not be published. Required fields are marked *

*