go: proposal: Go 2: `abort?` statement to bail early on non-nil errors

I’m aware of the long history of this particular topic. What’s different about this proposal is that it introduces a return construct conditional on errors. I’ve looked through previous proposals and none from what I saw build on the return statement. As I hope to show in the background section, keeping the current return semantics is the core constraint of any solution for this problem.

In a nutshell, I propose an abort? statement that transforms functions like this:

func (m Model) Delete(ctx context.Context, id int64) error {
	query := `DELETE FROM table WHERE id = $1`

	result, err := m.DB.ExecContext(ctx, query, id)
	if err != nil {
		return err
	}

	rowsAffected, err := result.RowsAffected()
	if err != nil {
		return err
	}

	if rowsAffected == 0 {
		return ErrRecordNotFound
	}
	return nil
}

into something like:

func (m Model) Delete(ctx context.Context, id int64) error {
	query := `DELETE FROM table WHERE id = $1`

	abort? result, err := m.DB.ExecContext(ctx, query, id); err
	abort? rowsAffected, err := result.RowsAffected(); err

	if rowsAffected == 0 {
		return ErrRecordNotFound
	}
	return nil
}

I think it’s helpful to systematically build up to a solution, so before diving in I want to build up an understanding of the problem. If my understanding of the problem is correct, then the solution is probably appropriate. However, if my understanding is flawed then the solution is likely also flawed, but we can at least catch that error and learn from it.

I tried my best to follow the language change template, but the questionnaire format was limiting as I prefer a systematic build up. However, I used the questions as a guide and all the relevant questions should be answered.

Background

Ironically, the “error-handling problem” is not about handling errors at all, but quite the opposite: not handling them and just bailing early.

It’s a problem that mostly affects what I’m calling mid-stack functions — functions that call other functions that can error but don’t have the context to decide how best to handle the error. These functions typically just return early and propagate the error to the caller. They have some variation of the familiar if err != nil { return ... } pattern.

Functions near the bottom of the stack are closer to the application’s entry point and thus have more context as to what to do on errors. Their code is likely to handle errors even if just to log or add a metric or exit altogether. The key characteristic for these is that after checking for an error, they don’t immediately return.

Functions near the top of the stack typically create errors though they might also contain parts resembling mid-stack functions if they happen to make system calls. They check for logical errors or invalid states and then return error values, but they typically don’t check for error values to begin with.

Why is this an issue just in Go?

This problem isn’t unique to Go, it’s something that happens in all languages. Whether or not you notice it is largely a matter of how the language treats errors.

Whenever an invalid state happens at the top of a call stack, you want to unwind quickly and get to functions inside the main application that have more context on what to do with that error.

Languages that use exceptions for errors throw an exception at the top of the stack and expect application functions to catch those exceptions. Mid-stack functions in these languages simply ignore the exception and let it bubble up. This transforms the problem into one of guessing which function might blow up. And even if it is clear which functions might throw, it means any function that throws forces the calling code to use a try/catch. It also means that if you want to transform or wrap an exception it means you have to catch one exception and throw another. But perhaps the biggest downside to this pattern is that it’s not clear what invalid states are truly exceptions.

Languages that return result types also encounter this problem and sometimes use some special construct to quickly return in the failure case. This is the most they can do since each function in the chain still has to return a result. Go is most similar to these languages. Though it doesn’t strictly have result types, Go’s return statement is powerful enough to mimic them. These languages have the downside of having to propagate errors up manually but they have an advantage in that they create a distinction between errors and truly exceptional states.

I think the most you can do in a language that treats errors as values is to create an abstraction that returns quickly when an error is encountered. Anything more powerful than that strays into exception territory.

Examples of Failing Early

Whenever designing an abstraction, it’s helpful to see the different variations in order to figure out what’s constant and what varies. We should be able to abstract away the constant elements while providing a way to express what varies. Let’s look at a few examples of functions that immediately bail once they encounter an error.

First, we need to account for the error being returned in any position in the result list of an inner function call:


func F1() error {
	err := G1()
	if err != nil {
		return err
	}
	// ...
	return nil
}

func F1() error {
	x, err := G2()
	if err != nil {
		return err
	}
	// ...
	return nil
}

func F1() error {
	x, y, err := G3()
	if err != nil {
		return err
	}
	// ...
	return nil
}

func F1() error {
	x, err, y := G3()
	if err != nil {
		return err
	}
	// ...
	return nil
}

We also need to account for functions that bail-early while returning multiple values:

func F2() (*A, error) {
	if err := G1(); err != nil {
		return nil, err
	}
	// ...
	return a, nil
}

func F3() (*A, B, error) {
	if err := G1(); err != nil {
		return nil, B{}, err
	}
	// ...
	return a, b, nil
}

We also might want to replace or wrap the error in some fashion before returning:

func F3() (*A, B, error) {
	if err := G1(); err != nil {
		return nil, B{}, fmt.Errorf("annotation: %w", err)
	}
	// ...
	return a, b, nil
}

var ErrNotFound = errors.New(“not found”)
func F2(id int64) (*A, error) {
	a, err := db.find(id)
	if err != nil {
		return nil, ErrNotFound
	}
	return a, nil
}

From the cases above, we can see that the constant in all these variations is the extraction and testing of err. The return statement can vary and so it can’t be abstracted away.

The core problem seems to be reducing the ceremony around extracting and testing for an error, while keeping the semantics of the return statement.

Proposed Solution

My proposal is an abort? statement that immediately bails from the function whenever it encounters a non-nil error, returning a specified list of values.

Code before abort?:

// from https://pkg.go.dev/github.com/dsnet/try#section-readme
func (a *MixedArray) UnmarshalNext(uo json.UnmarshalOptions, d *json.Decoder) error {
	switch t, err := d.ReadToken(); {
	case err != nil:
		return err
	case t.Kind() != '[':
		return fmt.Errorf("got %v, expecting array start", t.Kind())
	}

	if err := uo.UnmarshalNext(d, &a.Scalar); err != nil {
		return err
	}
	if err := uo.UnmarshalNext(d, &a.Slice); err != nil {
		return err
	}
	if err := uo.UnmarshalNext(d, &a.Map); err != nil {
		return err
	}

	switch t, err := d.ReadToken(); {
	case err != nil:
		return err
	case t.Kind() != ']':
		return fmt.Errorf("got %v, expecting array end", t.Kind())
	}
	return nil
}

Code with abort?:

func (a *MixedArray) UnmarshalNext(uo json.UnmarshalOptions, d *json.Decoder) error {
	abort? t, err := d.ReadToken(); err 

	if t.Kind() != '[' {
		return fmt.Errorf("got %v, expecting array start", t.Kind())
	}

	abort? uo.UnmarshalNext(d, &a.Scalar)
	abort? uo.UnmarshalNext(d, &a.Slice)
	abort? uo.UnmarshalNext(d, &a.Map)

	abort? t, err := d.ReadToken(); err

	if t.Kind() != ']' {
		return fmt.Errorf("got %v, expecting array end", t.Kind())
	}

	return nil
}

From the above code, we can see that there are 2 variants of abort?. A two clause version and a single-clause version.

Two clause version

abort? t, err := d.ReadToken(); err 

The first clause of abort expects an expression list that it uses to test for non-nil errors. In the above we’re using the left-hand side of the assignment (t, err) as the expression list. If any of these is a non-nil error, then the last clause is returned. As a result, the last clause is an expression list that MUST match the return signature of the function.

The assigned values are available to the surrounding scope. This strays slightly from how other statements with an assignment preamble work (e.g., if and switch) since they constrain assigned values to the following block. Since abort? doesn’t have a block, I think this behavior remains intuitive.

One clause version

The single-clause version is used when the first expression list used to test also matches the return signature of the function. We can separate the assignment to its own line and write the 2 clauses in 2 steps:

// with 2 clauses
abort? t, err := d.ReadToken(); err 

// in 2 steps
t, err := d.ReadToken()
abort? err; err 

But since the first list matches the second list we can just merge them into one:

t, err := d.ReadToken()
abort? err

That’s what’s happening for the abort? uo.UnmarshalNext(...) lines:

// expanded to 2 lines and 2 clauses
err := uo.UnmarshalNext(d, &a.Map)
abort? err; err

// single clause with err assignment
err := uo.UnmarshalNext(d, &a.Map)
abort? err

// using the return value of the function for both clauses
abort? uo.UnmarshalNext(d, &a.Map)

Definition

Formally, it can be defined as:

AbortStmt = "abort?" [ ExpressionList | Assignment ";" ] ExpressionList

The abort? statement tests its first ExpressionList for the presence of an error and checks if the error is not nil. If it is nil then execution of the current function continues. If the error is not nil, it returns the last ExpressionList.

In the case of an Assignment, the left-hand ExpressionList of the Assignment is used for testing for a non-nil error.

The last ExpressionList MUST match the return signature of the function. In the case of the single-clause version, the first and last ExpressionList are the same thing.

It’s illegal for the first ExpressionList to not contain an error type. We could check for this at compile time. This limits the blast radius of this change and makes it strictly for unwinding the stack without too much ceremony.

Examples

I think it’s worth showing a couple more examples to get a feel for how code might look with abort?. These are random functions from the standard library and kubernetes that contained instances of if err != nil { return ... }:

// Before
func (fsys dotFileHidingFileSystem) Open(name string) (http.File, error) {
	if containsDotFile(name) { // If dot file, return 403 response
		return nil, fs.ErrPermission
	}

	file, err := fsys.FileSystem.Open(name)
	if err != nil {
		return nil, err
	}
	return dotFileHidingFile{file}, nil
}

// After
func (fsys dotFileHidingFileSystem) Open(name string) (http.File, error) {
	if containsDotFile(name) { // If dot file, return 403 response
		return nil, fs.ErrPermission
	}
	abort? file, err := fsys.FileSystem.Open(name); nil, err
	
	return dotFileHidingFile{file}, nil
}
// Before
func (t *multiWriter) Write(p []byte) (n int, err error) {
	for _, w := range t.writers {
		n, err = w.Write(p)
		if err != nil {
			return
		}
		if n != len(p) {
			err = ErrShortWrite
			return
		}
	}
	return len(p), nil
}

// After
func (t *multiWriter) Write(p []byte) (int, error) {
	for _, w := range t.writers {
		abort? n, err := w.Write(p)
		if n != len(p) {
			return n, ErrShortWrite
		}
	}
	return len(p), nil
}
// Before
func (dc *DeploymentController) rolloutRolling(ctx context.Context, d *apps.Deployment, rsList []*apps.ReplicaSet) error {
	newRS, oldRSs, err := dc.getAllReplicaSetsAndSyncRevision(ctx, d, rsList, true)
	if err != nil {
		return err
	}
	allRSs := append(oldRSs, newRS)

	// Scale up, if we can.
	scaledUp, err := dc.reconcileNewReplicaSet(ctx, allRSs, newRS, d)
	if err != nil {
		return err
	}
	if scaledUp {
		// Update DeploymentStatus
		return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
	}

	// Scale down, if we can.
	scaledDown, err := dc.reconcileOldReplicaSets(ctx, allRSs, controller.FilterActiveReplicaSets(oldRSs), newRS, d)
	if err != nil {
		return err
	}
	if scaledDown {
		// Update DeploymentStatus
		return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
	}

	if deploymentutil.DeploymentComplete(d, &d.Status) {
		if err := dc.cleanupDeployment(ctx, oldRSs, d); err != nil {
			return err
		}
	}

	// Sync deployment status
	return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
}

// After
func (dc *DeploymentController) rolloutRolling(ctx context.Context, d *apps.Deployment, rsList []*apps.ReplicaSet) error {
	newRS, oldRSs, err := dc.getAllReplicaSetsAndSyncRevision(ctx, d, rsList, true)
	abort? err
	allRSs := append(oldRSs, newRS)

	// Scale up, if we can.
	scaledUp, err := dc.reconcileNewReplicaSet(ctx, allRSs, newRS, d)
	abort? err
	if scaledUp {
		// Update DeploymentStatus
		return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
	}

	// Scale down, if we can.
	scaledDown, err := dc.reconcileOldReplicaSets(ctx, allRSs, controller.FilterActiveReplicaSets(oldRSs), newRS, d)
	abort? err
	if scaledDown {
		// Update DeploymentStatus
		return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
	}

	if deploymentutil.DeploymentComplete(d, &d.Status) {
		abort? dc.cleanupDeployment(ctx, oldRSs, d)
	}

	// Sync deployment status
	return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
}

The last example is particularly interesting in that it highlights when you might want to skip putting things on a single line and instead use a separate assignment statement. abort? follows the usage of the if statement in that regard.

Closing Thoughts

This idea came to me randomly and fully-formed for the most part so I hope I’ve done it justice. Please let me know if there’s anything unclear or any edge-cases I didn’t cover.

Costs

There’s certainly a cost for adding a new keyword in both language and tooling. The compile-time and runtime costs should be roughly similar to the if err != nil { return ... } pattern since this is mostly an abstraction of that pattern rather than a new construct. I’m not familiar with the tooling implementation so I can’t speak to the cost of that, but hopefully I’ve provided enough information for those that are to deem whether or not this is worth the cost.

There’s also the cost of learning a new statement for both experienced Go programmers and those new to the language. I’ve been careful not to introduce any constructs that don’t already exist in the language. I think there is a cost here to be sure, but it’s a minimal one since this is mostly giving a name to a frequently used pattern.

Benefits

The core benefit of this change would be to reduce boilerplate and increase information density. It saves 2-3 lines of code each time it’s used. That’s not a lot in any singular case, but it compounds over multiple cases. As long as we’re not losing information when condensing the code then the abstraction is worth.

It’s important not just to reduce boilerplate, but to communicate the intention behind that boilerplate. I think abort? does a good job of being lossless in that regard.

Alternative Names Considered

My first version was abort to match the look of the other keywords in Go. That name was likely to cause issues because

  1. It would conflict with any existing code using abort as a package or variable name.
  2. It sounds like a command and not conditional at all. Adding the ? suffix solved both problems. It’s not a breaking change since ? is illegal in package/variable names. It also captures the optional nature of bailing early only when an error is exists.

return? was considered, but while it captures the same sentiment as abort?, it hurts the ability to quickly scan code because it’s only a single character away from return and it does nearly the same thing which would probably cause confusion and mistakes.

error? and try were also considered but they signal checking for errors rather than returning when one is found. try also has the added baggage of try/catch in other languages and teaching a new meaning on top of that would be fighting an unnecessary battle.

Comparison with Other Languages

I intentionally stayed away from discussing other languages since the design of abort? is based mostly on the return statement and I wanted to keep this focused on that. I mentioned earlier that Go is most similar to languages that have result types. If you’re curious how abort? compares with other “fail early” constructs, I think it’s an interesting exercise to rewrite code with the Result type in Go using abort?.

About this issue

  • Original URL
  • State: closed
  • Created 8 months ago
  • Reactions: 23
  • Comments: 24 (8 by maintainers)

Most upvoted comments

This looks like #46655, with check -> abort? and moving its position

I think a specific challenge with this particular genre of proposal is that there have been two different levels of disagreement interleaved in all of the previous discussions I’ve seen:

  1. Those who disagree with specific details of the proposal, such as exactly which new keywords/symbols to use, and what order they would go in.
  2. Those who disagree that eliminating the if statement and its indented block is even a desirable goal at all, regardless of the fine details of exactly how the proposed “shorthand” is shaped.

(Note: I am not arguing either of those points here. For the sake of this comment, I’m neutral on the specific proposal in this issue.)

I think some of the “this is similar to previous proposals” reaction might be thinking about it from the perspective of the second kind of objection: those who expressed that they don’t think this sort of goal is even desirable see this proposal as equivalent to all of the others because all it’s changed is some irrelevant (to them) details about where on the line each clause goes and what delimits them.

I think all proposals for shorthands to avoid writing a normal if statement seem doomed to this outcome: it’s unclear exactly what the rejection of the previous proposals represents – whether it’s a rejection of form 1 or form 2 above, or something else entirely – and so therefore it’s unclear whether a new proposal like this one constitutes “new information” to justify revisiting an earlier decision.

Perhaps it would be helpful to have a broader discussion (in a different place than this one issue) about what parts of “the error handling problem” seem to have sufficient consensus for it to be worth proposing solutions, what parts the proposals group feels have been retreaded enough to rule as “decided”, and what parts of the problem space remain murky and perhaps in need of further problem-space research.

@apparentlymart there is a 4th option or abandoning the locality of error handling (in terms of line vicinity but still within the bounds of the function). (according to the lines of https://github.com/golang/go/issues/32437) which would be my favorite but in the context of preserving locality your example nr. 2 could be simplified with appropriate support into something like

DoSomething() or return fmt.Errorf("doing something: %w", err) or res := DoSomething() or return err

it still gives priority to the happy path and reduces the information overload on error path. I’m not sure if it something like this can be implemented purely as sugar and the compiler then adds the usual if err != nil that at this point is just noise. On those cases that need a different condition (different than is not nill) the current pattern can be used.

Anyway I think that bridging the divide between those who think the happy path should be more readable and those who think that happy path is/should be no different than the error path is almost impossible as the history of countless proposals shows.

In that light I don’t agree that every proposal should be seen under the “is this the best possible proposal?” question. Maybe I’m wrong but I don’t see a technical way to solve what is a point of view difference. So maybe the question is, should go insist on “one true way” of doing error handling or supporting both views by chosing a second augmenting way in addition the current one.

I think that’s a helpful summarization @apparentlymart.

As pertains to having the an error handling keyword like abort? on the left-hand side, the reason for it is precisely that it occupies “prime real-estate” so to speak. It’s a trade-off – you are reducing 3-4 lines of code into a single line of code so you are effectively trading 2-3 extra lines for 6 characters at the front of the line. I don’t think it would be fair to readers to both compress the code and make it look like regular “happy-path” code.

I think it’s fair to assume that most programmers scan code for patterns rather than read code carefully line for line, at least initially.

I’d also like to push back a bit against the notion that errors don’t belong on the left-hand side. I think errors that force a function to immediately return are very different from errors that can be handled in any manner.

if err != nil { return ... } is effectively another way of indicating that “anything below these lines of code is invalid if err is not nil.” I don’t think it’s accurate to say you are burying the lead if the error is important enough to invalidate the rest of the code in the function. At that point, it is more important than the happy path which is why we currently spend 3 lines to make sure the error is not nil.

I obviously can’t speak to whether or not this constitutes an excellent change, but I will say if there is a construct powerful enough to both check for errors and return from the function, it should not be hidden somewhere programmers aren’t accustomed to scanning. It should be obvious that a function could potentially return at that point.

@apparentlymart Thanks for the comment but I don’t entirely agree with you. I think there are objections to this specific proposal that go beyond “we don’t need any syntax change.”

  1. Every error checking line starts with abort?, which buries the lede.
  2. The proposed syntax for abort? returning an annotated error is visibly different from all other Go syntax.

I agree that it’s very hard to satisfy everyone. But any syntactic change here would be the biggest change to the language other than generics. It would affect some 1% of all Go lines in existence. It really has to be an excellent change, not just adequate. And if we can’t make it excellent, we shouldn’t do it.

  1. Similar ideas have been proposed several times before.
  2. This kind of approach means that reading the code focuses on error handling, because every line starts with abort?. That makes the code somewhat harder to read.
  3. Early on you mention code that returns an annotated error, but I don’t see any examples of such code using abort?.