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.
Top comments (3)
All looks reasonable. I have only couple remarks:
Detailer
interface has no sense outside of interface/objecterrors.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!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.1: I'm saying it should be used instead of
errors.Printer
anderrors.Formatter
.errors.Formatter
is bad because this has more chance for programmer error. If the programmer forgets to printError()
, or return the next error, the purpose ofFormatter
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 implementerrors.Printer
, wherefmt.Print
doesn't have the same function asp.Print
. If we didn't want to violate SRP, we should be forced to implementfmt.Formatter
rather than a specialerrors.Formatter
.Using
Detailer
instead ofFormatter
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:To do this with
Formatter
, you would need to create a customerrors.Printer
and assume what iserror
vs what isdetails
, 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.
I seem that name of interface
Formatter
matches better to context offmt
module, than nameDetailer
. The nameDetailer
is better forerrors
module.