loading...

Go 2 Draft: Error Values

dean profile image Dean Bassett ・6 min read

Go 2 is being drafted.

If you haven't heard, there will be a few major language changes coming to Go 2. The Go team has recently submitted a draft of these changes, and as part of a 3-part series, I've been going through each part, explaining it, and state my opinion on them! This is the second part of the series. Last time I did error handling, and this time I will be discussing error values!

Remember that these are just drafts. They will most likely be added to the language, but there is still a chance that it won't. Also, remember that these drafts are not final, of course. The syntax will probably be a bit different from what it is right now.

These may not also be the only changes to the language, but these will probably be the only major changes.

I thought you already discussed errors!

Well, the first part was about error handling, and a new syntax which aides in handling errors. This part is about the improvements to the error interface and the errors package. There's some really good improvements that may be coming!

Okay, so what's wrong with the errors package?

There's two main issues: Inspecting errors, and Formatting errors.

Inspecting errors

Currently, there is no way to get context for an error without a custom implementation. So there's no provided way to provide context to an error you are returning, or to see if there was a cause for an error that you've received.

To fix this, the following may be defined in the errors package:

package errors

// Describes an error which wraps another error
type Wrapper interface {
    Unwrap() error
}

// Reports whether err or any of the errors in its chain is equal to target.
function Is(err, target error) bool

// Checks whether err or any of the errors in its chain is a value of type E.
// If so, it returns the discovered value of type E, with ok set to true.
// If not, it returns the zero value of type E, with ok set to false.
func As(type E)(err error) (e E, ok bool)

// NOTE this uses the syntax from the generics draft:
// https://go.googlesource.com/proposal/+/master/design/go2draft-generics-overview.md

Wrapper is an interface which describes an error which wraps another error. This forms a singly-linked list (or "chain") of errors, with the head as the most general error ("could not initialize config") and the tail as the root cause of the error ("file not found").

Is is the equivalent of the old if err == pack.ErrSomething, but it checks the entire chain of errors. This way, you could do if errors.Is(err, os.ErrNotExist) and it would tell you if at any point the error was caused by a file not existing.

As is the equivalent of the old smth, ok := err.(pack.ErrSomething), but it also checks the entire chain of errors. Notice that the As function uses generics, which I will cover in my next blog post.

Anyway, As's usage would look something along the lines of errne, ok := errors.As(*os.ErrNotExist)(err). I personally don't like the double-parentheses syntax for generics (to be honest, I don't like generics that much in general...)

If generics are not yet in the language, a good substitute would be

var errne *os.ErrNotExist
errors.AsValue(&errne, err)

Formatting errors

Also, when viewing printed errors, there is often very little information! Printing the error gives something like "can't read config" or "invalid path ", and no good way to get more detail.

To address this, the following may be added to the errors package:

type Formatter interface {
    Format(p Printer) (next error)
}

Formatter is an interface that describes an error which may be formatted. Printer is an interface which contains the functions Print and Printf, along with a Detail function. The Printer is provided package that is printing the error (usually fmt if printing via fmt.Print...()).

Then, fmt.Errorf() could then be improved to return an error implementing Formatter. It is also suggested that if the format string ends with : %v or : %s that it should return a Wrapper as well, thus improving the returned error even more.

Errors with custom formats could look like:

// Implements error and errors.Formatter
type ConfigError struct {
    File     string       // A human-readable name for this config
    Source   string       // The source file and line this error occurred on
    CausedBy error        // The error this was caused by
}

func (e *ConfigError) Init(file string, cause error) {
    e.File = file

    if _, file, line, ok := runtime.Caller(1); ok {
        e.Source = fmt.Sprintf("%s:%s", )
    } else {
        e.Source = "error source unavailable"
    }

    e.CausedBy = cause
}

func (e *ConfigError) Format(p errors.Printer) (next error) {
    p.Printf("error reading config %s", e.File)
    if p.Detail() { // if printing with `%+v` rather than just `%v`
        p.Printf("Perhaps incorrect file name or invalid format?")
        p.Print(e.Source)
    }
    return e.CausedBy
}

func (e *ConfigError) Error() string {
    return fmt.Sprint(e) // `fmt` will call the new Format function
}

And then when it's printed, it'll be something like...

fmt.Printf("%v", err)

error reading config MyConfig

===

fmt.Printf("%+v", err)

error reading config MyConfig:
    Perhaps incorrect file name or invalid format?
    github.com/deanveloper/config:1337
--- file does not exist

Nifty!

Putting these both together

Now, let's add one more function to our ConfigError in order to complete it. Let's add our Unwrap function so that we can use errors.Is and errors.As with it!

// Implements error, errors.Wrapper, and errors.Formatter
type ConfigError struct {
    File     string       // A human-readable name for this config
    Source   string       // The source file and line this error occurred on
    CausedBy error        // The error this was caused by
}

func (e *ConfigError) Init(file string, cause error) {
    e.File = file

    if _, file, line, ok := runtime.Caller(1); ok {
        e.Source = fmt.Sprintf("%s:%s", )
    } else {
        e.Source = "error source unavailable"
    }

    e.CausedBy = cause
}

func (e *ConfigError) Format(p errors.Printer) (next error) {
    p.Printf("error reading config %s", e.File)
    if p.Detail() { // if printing with `%+v` rather than just `%v`
        p.Printf("Perhaps incorrect file name or invalid format?")
        p.Print(e.Source)
    }
    return e.CausedBy
}

func (e *ConfigError) Unwrap() error {
    return e.CausedBy
}

func (e *ConfigError) Error() string {
    return fmt.Sprint(e) // `fmt` will call the new Format function
}

And now we can use this error (note, I am using syntax from the "error handling" Go 2 proposal. I have a blog post on that one already)


func main() {
    handle err {
        if cfgErr, ok := errors.As(*config.ConfigError)(err); ok {
            if errors.Is(err, os.ErrNotFound) {
                // Nice, clean output if we couldn't find the file
                log.Fatalln("Could not find config file %s", cfgErr.File)
            }
        }
        // Hmm, let's get a more verbose output.
        // Maybe a parsing error?
        log.Fatalln("%+v", err)
    }

    check config.Create("MyConfig")
}

My Suggestions

In terms of errors.Is, I'd really like a way to check multiple errors.

Perhaps instead of errors.Is(root, cause error) bool we could have errors.IsAny(root error, causes ...error) error (terrible name, but the semantics are what's important), which we could then use a switch err on. It's very frustrating to not have an efficient way to check multiple types without looping ourselves.

Also, I think this is adding a lot of complexity to error values. To me, one of the things special about Go was that errors were simple. I like the Wrapper idea, along with errors.Is and errors.As, but the Formatter I think is a bit much. Maybe add a type Detailer interface { Detail() string } to errors and let fmt take care of the actual formatting, as pretty much all Format functions will be pretty similar...

func (e *MyErr) Format(p errors.Printer) (next error) {
    p.Print(/* short error string */)
    if p.Detail() {
        p.Print(/* details */)
    }
    return /* caused by */
}

Wait a second, all the things I wrote placeholders for could just be functions!

If Go just had an errors.Detailer interface, then our ConfigError would look like...

// Implements error, errors.Wrapper, and errors.Detailer
type ConfigError struct {
    File     string       // A human-readable name for this config
    Source   string       // The source file and line this error occurred on
    CausedBy error        // The error this was caused by
}

func (e *ConfigError) Init(file string, cause error) {
    e.File = file

    if _, file, line, ok := runtime.Caller(1); ok {
        e.Source = fmt.Sprintf("%s:%s", )
    } else {
        e.Source = "error source unavailable"
    }

    e.CausedBy = cause
}

func (e *ConfigError) Detail() string {
    return "Perhaps incorrect file name or invalid format?\n" + e.Source
}

func (e *ConfigError) Unwrap() error {
    return e.CausedBy
}

func (e *ConfigError) Error() string {
    return "error reading config " + e.File
}

And then fmt would be able to print each error in the chain.

All in all...

I think these are welcome changes, and my changes are a bit nitpicky. I'm fine with the Format function, even though I think that just adding a Detailer interface would be better. I will be making that as a suggestion to the Go team.

Discussion

pic
Editor guide
Collapse
northbear profile image
northbear

All looks reasonable. I have only couple remarks:

  1. Detailer interface has no sense outside of interface/object errors.Printer context. I don't see any reason, to make it dedicated from Printer type. The 'fmt' is just module to output formatted texts. Adding additional responsibility to 'fmt' module looks like not good idea. SRP - forever!
  2. I don't see cases for using error.IsAny(...) that you suggested. The chained errors will belong different levels of abstraction and they should be checked on their levels. I suppose It'd be useful to implement ability to use errors object with type switch expression.
Collapse
dean profile image
Dean Bassett Author

1: I'm saying it should be used instead of errors.Printer and errors.Formatter.

errors.Formatter is bad because this has more chance for programmer error. If the programmer forgets to print Error(), or return the next error, the purpose of Formatter has an extremely negative impact on anything trying to print it. It leaves for too much human error.

This doesn't violate SRP any more than errors.Formatter does. Either way, fmt, golang.org/x/text/message, etc. will each need to implement errors.Printer, where fmt.Print doesn't have the same function as p.Print. If we didn't want to violate SRP, we should be forced to implement fmt.Formatter rather than a special errors.Formatter.

Using Detailer instead of Formatter also allows for much more parsable errors. For instance, if I wanted JSON for my error, I can now very easily parse my error to:

{
    "error": /* err.Error() */
    "details": /* err.Details() (if implementing) */
    "wraps": /* err.Unwrap() (if implementing) */
}

To do this with Formatter, you would need to create a custom errors.Printer and assume what is error vs what is details, because it is not clear as to what's what.

2: Yeah, honestly the more I think about it, the more I think that it should just be a function that the developer creates for their own needs.

Collapse
northbear profile image
northbear

I seem that name of interfaceFormatter matches better to context of fmt module, than name Detailer. The name Detailer is better for errors module.