Skip to content

Instantly share code, notes, and snippets.

@Space-Tide
Last active April 17, 2022 01:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Space-Tide/e96284861434b46c6c730f9c73024373 to your computer and use it in GitHub Desktop.
Save Space-Tide/e96284861434b46c6c730f9c73024373 to your computer and use it in GitHub Desktop.

Go 2 error handling proposal

Preamble

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.

Core Concept

There are two parts to the core concept.

  1. 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
  1. 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.

Example Code

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.

Details

  1. 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.
  1. By default, a function which is passed a variable with a non-nil ERROR will not execute.

    1. A default error will be generated. This document will call the error ErrUnhandledParameterError.

    2. 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.

    3. If the last return variable is an error, it will be set to ErrUnhandledParameterError.

    4. Any other return variables will be returned as their zero types with an attached ERROR set to ErrUnhandledParameterError.

    5. 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.
  1. 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
  1. 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
} 
  1. 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
  1. 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.

Example Walkthrough

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.

Scenario 1

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
}

Scenario 2

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
}

Scenario 3

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
}

Discussion

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.

Example Code which returns errors ignored by the original code

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
}

Method Chaining

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
}

Optional Parameters

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

Conclusion

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.

@seankhliao
Copy link

At first glance, this looks like implicitly making every variable a Result<> type (to borrow from other languages), which as Ian mentioned would be costly. But with the added control flow changes, it's unclear why you would need so many identifiers if only one thing can be an error, since it causes ~everything (unclear conditions of "fail") else to be skipped.
It also reminds me of the goto cleanup style of error handling.

It looks like you've also changed what := does, it can now sometimes combine declared return values into a single variable?
Does that mean code that is now err1, err2 := foo() becomes err := foo9), err2 := err@error?

On error wrapping, without reverting to an if err != nil {} after every possible error value, there doesn't seem to be a way to annotate the errors with a per error message (which appears to be the most common usage), and any stacktrace generated wouldn't be very helpful.

As a reader of the code, while it is less lines, it is also much more inconsistent, and unclear as to what the failure modes for a particular piece of code would be (if everything is hidden behind a ...@error)

@ianlancetaylor
Copy link

A map could be used to map a variable's memory address to an error's address. myVar@error requests the error which is mapped to myVar's address. If there is none, it returns nil. This would also mean the GC would have to delete the corresponding pair from the map when cleaning.

That would have to be a concurrent map. Programs with hundreds of thousands of goroutines managing network connections, which would presumably regularly encounter network errors and would certainly have to regularly check for network errors, would have massive contention on the map.

Also, as far as I can tell this would mean that every variable that could have a @error annotation would escape to the heap, which would be a big performance loss.

Alternatively, behind the scenes, variables with attached errors could be kept as separate types. The compiler would decide which to use when and convert between the two when needed.

I don't see how this helps when the variable is converted to an interface type.

The general @error syntax is completely different from anything else in Go. That is unfortunate.

Can you clarify what the objection or difficulty here is?

There is nothing in Go that uses the @ sign (as you say). There is nothing in Go that adds attributes to values. It's not immediately obvious to someone learning the language why the @error syntax, which looks at first like an attribute syntax, can only be used with @error. Why not @positive? @valid?

Thanks for the interesting idea. I don't plan to discuss this further here, because my time for this kind of thing is limited. I hope that other people will be able to comment here or in some other forum such as golang-nuts. Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment