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.

@Space-Tide
Copy link
Author

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

@ianlancetaylor
Copy link

Thanks for writing this up. It's clear that you've thought about this.

That said, this seems costly to implement. Almost any variable can have a @error value, and that value has type error. I can store any value in a variable of interface type, and presumably the @error value will go along for the ride. That means that in general every interface value requires an additional pair of pointers, which for the common case of storing a pointer in an interface means that we can no longer simply store the pointer, but must instead allocate a new block of memory to hold the pointer and the @error value. Even when there is no @error value we need some way for the interface value to indicate that the @error value is not present; it's not clear how to do that without requiring an additional allocation. An additional allocation for most interface values would be very expensive.

You've introduced new names like ErrFail and ErrUnhandledParameterError but it's not clear to me where those names are defined.

You've said that calling a function with any value with an @error value will fail immediately. But you also show calls to errors.Wrap that I think handle values with @error values without failing immediately. It's not clear to me why that works.

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

I doubt that this idea will be adopted into Go in anything like its present form.

All just my opinion, of course. Thanks for the proposal.

@Space-Tide
Copy link
Author

@ianlancetaylor

Thank you for your feedback. It is greatly appreciated. I want to work through your criticisms, and would find your continuing input invaluable. I believe my proposal is quite different from anything else. It is my hope that, even if this proposal doesn't make it, that I can make it good enough to inspire someone to make something that is.

First, let me note, that while I am reasonable proficient with a few languages (including Go), I am not an expert in anything CS related. Go is a language that speaks to me. And for that reason, I am really invested in Go 2 finding a good error handling system. While the current system is good, I'd like whatever Go 2 has to be better. That said, my view is as a programmer who uses and prefers to use Go (and not of an engineer).

That said, this seems costly to implement.

This is an anticipated objection. I have some ideas about this, but since I am not expert, I will have to rely on people with greater expertise to evaluate if any of them are feasible (or for them to make a reasonable proposal). Given that we would only need to keep track of a pointer to an error and only for variables which have non-nil errors, I have a bit of hope this objection could be overcome. Here are the two possibilities that I see:

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

You've introduced new names like ErrFail and ErrUnhandledParameterError but it's not clear to me where those names are defined.

Noted. I will edit the post to provide clarity. ErrFail was intended to be a placeholder for any error just as myVar is for any variable. And ErrUnhandledParameterError was intended to be a place holder for the default error returned when a function fails to execute because it was passed a variable with an attached error (to a parameter not designed to receive attached errors). I was a bit undecided as to whether it should be a language level pre-defined error or an error type that wraps the incoming error (possibly with additional context). I will go back and commit to one (wrapping) for clarity.

You've said that calling a function with any value with an @error value will fail immediately. But you also show calls to errors.Wrap that I think handle values with @error values without failing immediately. It's not clear to me why that works.

errors.Wrap's arguments are of type error, rather than being variables with an attached @error. The variables being passed to the function do not have an attached @error. If they did, errors.Wrap would fail to execute.

myVar@error has a type of error.

In the example code, the type being passed to errors.Wrap is error.

errors.Wrap(r@error, w@error, errCopy, errClose)

I'll go back and review the post and see where I can make that more clear. I am open to any suggestions, of course.

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? While reviewing various proposal in the Go ecosystem, a common objection is that the syntax is too cryptic. This guides me to using a word rather than just a symbol for syntax. I see myVar@error as being completely analogous to myVar.error where error would be a field for whatever struct myVar is. However, I couldn't use myVar.error as that would potentially break existing code. I chose the @ symbol as it seems to be completely unused in Go to prevent any unintentional Go 1 code breaking in the spec.

Are you saying the concept of a variable having an error is too different? Or that the symbols I chose to express and interact with that concept are too different? (Or something else?)

I doubt that this idea will be adopted into Go in anything like its present form.
All just my opinion, of course. Thanks for the proposal.

I greatly appreciate your feedback. I will use it to tighten up my proposal.

@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