The Go community is looking for a new error handling system that will be more lightweight, remain explicit, and be easy to read (not cryptic) all while keeping existing code valid. This proposal has a few core concepts that meet those needs. In addition to meeting those needs, this proposal will also allow for method chaining and optional variables – all without compromising Go principles. See Error Handling — Problem Overview
In Go, errors are values. Additionally, since Go supports multivalue return statements, functions often return errors as the last of the return values. These are two fundamental features which shape error handling in Go, which I will summarize this way:
- Errors are values
- Functions can have errors (and return them)
This proposal will add another concept to compliment those two concepts. In addition to functions being able to have and express an error, any variable will be able to have and express errors as well. This will add a third, fundamental feature:
- Variables can have errors (attached to them)
This extra dimension of expressiveness nicely compliments existing Go error handling. In fact, as developed below, it will effectively make the example technique for avoiding repetitive error handling given in errors are values trivially implemented.
There are two parts to the core concept.
- All variables can have an error value associated with them. For this proposal, the data of type T stored in a variable of type T is referred to by DATA. The error associated with that variable is referred to as ERROR. All current variable syntax remains valid for using and working with the DATA. There will be a special syntax for accessing the attached error. For this proposal the following syntax will be used:
MyVar@error //this accesses the ERROR attached to MyVar
- Functions which are passed variables with attached errors will, by default, not execute. Unless explicitly designed to handle errors, they will instead return errors and/or variables with attached ERRORs.
This change will allow Go 2 to keep the good things about Go errors (explicit, easy to read, not cryptic) while drastically reducing the amount of text and boilerplate code needed. Other details which are needed will be discussed later in this document.
Additionally, being able to attach an ERROR to a variable allows for more expressive errors. If a function returns multiple variables, an error could be attached to a single one of the return values if desired. This also allows for bad DATA to have the ERROR follow it, so that wherever that variable is accessed, the ERROR is also accessible.
Before getting into the details of this proposal, I will show a current code snippet and what it will look like when it is rewritten to demonstrate how much better the code will look compared to current Go code and other proposals. Afterwards, I will discuss the specific details beyond the core concept. The following code comes from Error Handling — Problem Overview
//Original Code written with current Go errors
func CopyFile(src, dst string) error {
r, err := os.Open(src)
if err != nil {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
if _, err := io.Copy(w, r); err != nil {
w.Close()
os.Remove(dst)
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
if err := w.Close(); err != nil {
os.Remove(dst)
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
}
Here is the same code, rewritten with this proposal. To fully understand this example code, you will need to read the details and walk-through section. For now, appreciate the light weight nature of the code.
//Code re-written with this proposal
func CopyFile(src, dst string) error {
r := os.Open(src)
defer r.Close()
w := os.Create(dst)
errCreate := w@error
_, errCopy := io.Copy(w, r)
errClose := w.Close()
if errs := errors.Wrap(r@error, w@error, errCopy, errClose); errs != nil {
if errCreate == nil {
os.Remove(dst)
}
return fmt.Errorf("copy %s %s: %v", src, dst, errs)
}
return nil
}
The original code has 20 lines of code with a lot of repetitive boilerplate error handling. The rewritten code has only 15 lines of code and none of the code is repeated or boilerplate. Using the core concept of this proposal along with the details discussed below, the rewritten code retains all of the same error handling logic of the original code.
- All variables can have an ERROR value attached to them. The following syntax is used:
var myVar T // myVar of type T.
myVar@error // the ERROR attached to myVar
myVar@error = ErrFail //set myVar ERROR to ErrFail
myVar@error = nil //clears the associated error
err := myVar@error // sets err equal to the ERROR of myVar
// if there is no associated ERROR, err is assigned nil
myOtherVar := myVar //myOtherVar is set to myVar
//if myVar has an attached ERROR, the same ERROR is attached to myOtherVar
myVar == someOtherVar // evaluates DATA only. True if DATA is equal.
-
By default, a function which is passed a variable with a non-nil ERROR will not execute.
-
A default error will be generated. This document will call the error ErrUnhandledParameterError.
-
At a minimum, ErrUnhandledParameterError should indicate that the function did not run because of an unhandled ERROR (either from a parameter or receiver). However, it would be best if the attached ERROR was the offending ERROR wrapped with the additional context of the functions name and the variable name.
-
If the last return variable is an error, it will be set to ErrUnhandledParameterError.
-
Any other return variables will be returned as their zero types with an attached ERROR set to ErrUnhandledParameterError.
-
Any pointer receiver will have an ERROR attached, but remain otherwise unchanged.
-
myVar := 5
myVar@error = ErrFail
func squareInteger(a int) {
return a * a
}
myOtherVar := squareInteger(myVar) //by default, squareInteger will not execute.
//myOtherVar is set to a zero and has
//ErrUnhandledParameterError as the attached ERROR
myVar := 5
myVar@error = ErrFail
func printPositiveInt(a int) error {
if a >= 0 {
fmt.Println(a)
} else {
return ErrFail
}
}
myErr := printPositiveInt(myVar) //by default, printPositiveInt will not execute.
//myErr is set to ErrUnhandledParameterError
And the analogous code for a method
myVar := new(myType)
myVar@error := ErrFail
func (t *myType) Copy() *myType {
//code for making and returning copy
}
myOtherVar := myVar.Copy() //by default, the Copy() method will not execute.
//Instead of returning a copy of myVar, it will return the zero value
//of a myType with ErrUnhandledParameterError as the attached error
//Additionally, if Copy uses a pointer receiver then
//the myVar will get ErrUnhandledParameterError attached
func (t *myType) MyOtherMethod() (var1 int) {
//code returns an int
}
myInt := myVar.MyOtherMethod()
//if t has an attached ERROR, the function will not run
//var1 is set to its zero value with var1@error set to ErrUnhandledParameterError
//since the receiver, t, is a pointer, it will also
//have the attached ERROR set to ErrUnhandledParameterError
//NOTE: if t were instead a value receiver, t would not be changed. i.e. no ERROR would be attached to it.
- A function may use a special signature to indicate that it will run even if passed variable with ERROR. This can be used, among other things, for error handling functions.
func MyFunc(var1 int@, var2 int) (var3 int)
//if passed a var1 with ERROR, the function will run
And for methods
func (t *myType@) MyMethod() (var1 int)
//if t has an attached ERROR, the function will run
- A function may not return a variable with an attached ERROR, unless the return signature specifically allows it. Attempting to do so results in a compile time error. This is to make it obvious when a function has been designed to return attached errors to any specific variable. This way, a programmer will know by the signature alone where errors may be returned.
func myFunc1() int {
a := 5
a@error = ErrFail
return a //compile time error
}
func myFunc2() int@ {
a := 5
a@error = ErrFail
return a //compiles
}
- When a deferred function’s arguments are evaluated (when the defer statement is evaluated) the attached ERROR is also evaluated.
func OpenDB() (*Database) {
//opens and returns database
}
myDB := OpenDB() //OpenDB is successful. myDB has no ERROR
defer myDB.Close() //myDB@error is evaluated now and is nil
//this statement will execute
myDB.MethodThatMakesERROR() //myDB now has an ERROR
//this does not affect the defer statement
- errors.Wrap(errs …error) is a variadic function which wraps the errors passed to it. Example implementation.
- The first error is the innermost error.
- It is implemented such that the recursive functionality of errors.Is and errors.As can check each of the wrapped errors (unlike the fmt.Errorf).
- The Error method of the returned type will recursively build an error string utilizing all of the errors.
- If an argument is nil, that argument is skipped.
- If all arguments are nil, it returns nil.
errs := errors.Wrap(Err1, Err2, nil, Err3)
// Err3 wraps Err2 which wraps Err1. nil is skipped.
errors.Is(errs, Err2) // Returns true.
errors.As(errs, &myCustomErrorType{}) //returns true if one of the errors' types is myCustomErrorType
errs.Error() //returns a string equivalent to
//Err3.Error() + ": " to Err2.Error() + ": " to Err1.Error()
errors.Wrap(nil, nil, nil) //returns nil
These details are enough to walk through the example and demonstrate its practical equivalence to the much more verbose Go 1 code.
In the example, it is assumed that os.open and os.create would now use this proposal and return a single value with an attached error. io.Copy is unchanged. Its return value, the number of lines, could be a valid number even if the function encountered an error. Since an io.Copy error would not be communicating an error about its return value, it would not make sense to attach the error to the return value. w.Close also remains unchanged.
The following three scenarios should be enough to demonstrate how the code will perform for all four error scenarios handled by the original code.
Opening the src file fails
func CopyFile(src, dst string) error {
r := os.Open(src) //os.Open fails. r has an ERROR value attached
defer r.Close() //r.Close will not execute because r has an ERROR
w := os.Create(dst) //executes normally
errCreate := w@error //errCreate is nil
_, errCopy := io.Copy(w, r) //this will not execute because r has an ERROR
//errCopy is set to ErrUnhandledParameterError
errClose := w.Close() //executes normally, errClose is nil
if errs := errors.Wrap(r@error, w@error, errCopy, errClose); errs != nil { //errs is not nil
if errCreate == nil {
os.Remove(dst) //this will execute since errCreate is nil
}
return fmt.Errorf("copy %s %s: %v", src, dst, errs) //an error is returned
}
return nil
}
Creating dst file fails
func CopyFile(src, dst string) error {
r := os.Open(src) //executes normally
defer r.Close() //will execute normally
w := os.Create(dst) //os.Create fails. w has an attached ERROR
errCreate := w@error //errCreate is set to w@error which is non-nil
_, errCopy := io.Copy(w, r) //this will not execute because w has an ERROR
//errCopy is set to ErrUnhandledParameterError
errClose := w.Close() //this will not execute because w has an ERROR
//errClose is set to ErrUnhandledParameterError
if errs := errors.Wrap(r@error, w@error, errCopy, errClose); errs != nil { //errs is not nil
if errCreate == nil{
os.Remove(dst) //this will NOT execute since errCreate is not nil
}
return fmt.Errorf("copy %s %s: %v", src, dst, errs) //an error is returned
}
return nil
}
io.Copy fails
func CopyFile(src, dst string) error {
r := os.Open(src) //executes normally
defer r.Close() //this will execute normally
w := os.Create(dst) //executes normally
errCreate := w@error //errCreate is nil
_, errCopy := io.Copy(w, r) //io.Copy fails. errCopy is assigned the error
errClose := w.Close() //executes normally
if errs := errors.Wrap(r@error, w@error, errCopy, errClose); errs != nil { //errs is not nil
if errCreate == nil {
os.Remove(dst) //this will execute since errCreate is nil
}
return fmt.Errorf("copy %s %s: %v", src, dst, errs) //error statement prints
}
return nil
}
These examples demonstrate how the errors in Go 2 can be handled with no repetitive or boilerplate error handling. The code is in a logical, readable order. And this proposal does not interfere with existing code. It is merely an extension of the current error handling in Go. In fact, it relies on current Go handling syntax to work as was the case with io.Copy. The only major difference compared to the original is that os.Create is called for the write file even if the call to open the read file has already failed. This causes no additional error issues as the code to close the dst file and remove it will also be called in such a case. In a situation where this would cause an unacceptable performance hit, a check can be performed prior to executing os.Create.
The original code does not return errors generated by the deferred call or by the error handling within the function. Now that the syntax is lightweight enough, it is easier to see and address those issues.
func CopyFile(src, dst string) (errs error) { //returned error must be named so that
//the deferred anonymous function can access it
r := os.Open(src)
defer func(){ //deferred function needs to be anonymous to properly access errs
errDeferClose := r.Close()
errs = errors.Wrap(errs, errDeferClose) //errs will now include this error
}
w := os.Create(dst)
errCreate = w@error
_, errCopy := io.Copy(w, r)
errClose := w.Close()
if errs = errors.Wrap(r@error, w@error, errCopy, errClose); errs != nil {
if errCreate == nil {
errRemove := os.Remove(dst) //capture error (if any)
errs = errors.Wrap(errs, errRemove) //errs will now include this error
}
return fmt.Errorf("copy %s %s: %v", src, dst, errs)
}
return nil
}
One weakness of the current Go error handling is needing to have two separate return variables to pass and subsequently handle errors. This prevents using method chaining. This proposal provides the functionality needed to allow for method chaining. myShape.OffsetX(5).OffsetY(20).Rotate(30) //The proposal allows for this to work and errors to be handled
Using only what has been proposed so far, this is possible.
Paragraph 2.ii mentions two possibilities: either ErrUnhandledParameterError wraps the original error with additional context, or it does not. If it does not, a method can be made to handle errors and add the additional context on its own.
func (t *myShapeType@) OffsetX(i int) (r *myShapeType) {//the @ indicates this function will run even
//with an error attached to the receiver
//this sort of code is only needed if ErrUnhandledParameterError does not wrap
//the original error with additional context.
//This is boilerplate is an argument that ErrUnhandledParameterError SHOULD wrap the
//original error with additional context.
if myShapeType@error != nil {
t@error = fmt.Errorf("OffSetX receiver error: %w", myShapeType@error)
}
//rest of code
}
The above wraps the incoming error with the text "OffSetX receiver error: " which allows for a more meaningful error to return when chain calling. While this is certainly serviceable, it could be possible to define default behavior to provide equally as much information to further reduce boilerplate.
Additional syntactic sugar
There are several other features which could be included. This section discusses helpful additions that are less central to the core concept.
Syntactic sugar for setting a variable to its zero value and setting its ERROR to a specified value
//myVar is a variable of myType
myVar = @ErrMyOhMy //syntactic sugar for the following
myVar = myType{}
myVar@error = ErrMyOhMy
This would allow for very succinct return statements when an error is produced.
myFunc() int {
//code
if (somethingBad) {
return @ErrSomethingBad //returns the zero value for the return variable
//with ERROR set to ErrSomethingBad
}
//more code
}
Because this proposal allows us to attach ERRORs to variables, we can specify that a variable with a zero value has not been set (rather than having been set to zero). This additional information allows us to create functions with optional parameters.
NotSet = errors.New("value not set") //used for optional parameter
func myFunc(myOptionalParameter int@) {
if myOptionalParameter@error == NotSet {
myOptionalParameter = MyDefaultValue // note: myOptionalParameter will now have no ERROR
}
if myOptionalParameter@error != nil {
//handle other possible errors.
}
//other code
}
//call the function specifying the parameter
myFunc(3)
//call the function without specifying the parameter
myFunc(@NotSet)
Syntactic sugar for simultaneously setting a variable DATA and ERROR
myVar := 5@ErrFail
//syntactic sugar for
myVar := 5
myVar@error = ErrFail
This proposal is lightweight. The example walkthrough illustrates how it accomplishes all of the same error handling of current Go, but with a lot less code. None of the error handling statements were repetitive boilerplate. It also allows for method chaining and explicitly optional parameters.
This proposal keeps error handling explicit. All of the error checking and handling is plainly visible. The default behavior of functions and methods not executing when a receiver or parameter has an attached ERROR enforces this. It makes sense that functions should not execute when passed DATA with an ERROR unless they are explicitly designed to do so. It also makes not handling errors verbose. One would have to clear the error from a variable to pass it. This lowers the relative burden of handling errors. Errors cannot simply be ignored by writing no code.
This proposal produces code that is easy to read. It is evident by a function's signature whether it is designed to handle errors and whether it can attach errors to return variables.
All existing Go code remains valid. All of the new functionality is driven with the @ symbol.
@ianlancetaylor @DeedleFake @seankhliao @songmelted @mewmew @networkimprov @markusheukelom @natefinch
Hello all, I have been working on this Go 2 error handling proposal for a little while and I am looking for feedback to develop this concept further. I have tagged you either because I have seen you express thoughts that resonated with me on this topic, or because you seemed generally knowledgeable and active on this topic.
Please take a look and offer any constructive criticism you may have. I would like to consider your thoughts and refine this proposal.
Thank you.