Skip to content

proposal: spec: try-handle keywords for reducing common error handling boilerplate #73376

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
jimmyfrasche opened this issue Apr 14, 2025 · 29 comments
Labels
error-handling Language & library change proposals that are about error handling. LanguageProposal Issues describing a requested change to the Go language specification. Proposal
Milestone

Comments

@jimmyfrasche
Copy link
Member

Proposal Details

This is another error handling proposal—heavily influenced by the check/handle draft and subsequent issues, especially #32437, #71203, and #69045. It introduces two keywords, try and handle.

The goal is not to replace all error handling. The goal is to replace the most common case, returning an error with some optional handling. For the uncommon cases, errors are still regular values and there is still the rest of the language to deal with them.

In a function whose last return is an error, try expr returns early when there's an error. All other returns are the zero value of their respective types.

func example() (int, error) {
	x := try f()
	return 2*x, nil
}

You can handle the error before it is returned with try expr handle expr where the second expr evaluates to a handler function.

There are three kinds of handler function

  1. one that takes an error and returns an error: err = h(err)
  2. one that takes an error and returns nothing: h(err)
  3. one that takes no error and returns nothing: h()

The first allows you to transform the error, for example, to wrap it or return a different value entirely. The last two allow you to respond to an error without modifying it, for example, to log the error or clean up an intermediary resource.

Here's the previous example with a handler, h:

func example() (int, error) {
	x := try f() handle h
	return 2*x, nil
}

The handler is only called when the error returned by f is not nil. The handler is only ever passed the error.

There is no way for a handler to stop the function from returning. It may only react.

Often the same handler logic can be applied to many errors. Rather than repeating the handler on every try, a handler may be placed on the defer stack with defer handle expr.

func example() (int, error) {
	defer handle h
	x := try f()
	return 2*x, nil
}

While the try expr handle expr only evaluates the handler when the try expression returns an error, the defer handle expr evaluates the handler whenever a nonnil error is returned, so it can also be used without try in functions that return an error.

A function that does not return an error may use try and handle but only when the last handler executed returns nothing. Since there is no error to return, we need a handler that takes responsibility for what happens with the error in lieu of returning it. Once such a handler is in place, everything else behaves as it does for a function that returns an error: any handler may be deferred or used with try and try may be used without handle.

This can be as simple as adding one of these to the top of the function:

  • defer handle panic
  • defer handle log.Fatal
  • defer handle t.Fatal

This same logic also allows package level variables to use tryhandle as long as the handler does not have a return, for example:

var prog = try compile(src) handle panic

try, at least initially, is limited to assignments and expression statements.

The following sections should help clarify the proposal but are not strictly necessary to read. However, if you have a question, you may want to at least skim them to see if it is answered there.

Examples

func Run() error {
	try Start()
	try Wait()
	return nil
}
func CopyFile(src, dst string) error {
	defer handle func(err error) error {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}
	r := try os.Open(src)
	defer r.Close()

	w := try os.Create(dst)
	defer w.Close()
	defer handle func() {
		os.Remove(dst)
	}

	try io.Copy(w, r)
	return w.Close()
}
func MustOpen(n string) *os.File {
	return try os.Open(n) handle panic
}
func TestFileData(t *testing.T) {
	defer handle t.Fatal
	f := try os.Open("testfile")
	// ...
}
func CreateIfNotExist(name string) error {
	f := try os.OpenFile(name, os.O_EXCL|os.O_WRONLY, 0o666) handle func(err error) error {
		if errors.Is(err, fs.ErrExist) {
			return nil
		}
		return err
	}
	// write to f ...
}

Possible changes to std

As handlers are simple functions, libraries and frameworks may define their own as appropriate. These are examples of some that could be added to std in follow up proposals. How they would shorten the example code above is left as an exercise to the reader.

One of the most common handlers needed is one that wraps an error with some additional context. Something like the below should be added to fmt.

package fmt
// Wrapf returns an error handler that wraps an error with [Errorf].
// The error will always be passed as the last argument.
// The caller must include %w or %v in the format string.
//
// Special case: [io.EOF] is returned unwrapped.
func Wrapf(format string, args ...any) func(error) error {
	args = slices.Clip(args)
	return func(err error) error {
		if err == io.EOF {
			return err
		}
		return fmt.Errorf(format, append(args, err)...)
	}
}

Another common case is ignoring a specific sentinel (return nil on io.EOF or fs.ErrExist for example) and this can be simplified with something like:

package errors
// Ignore returns an error handler that returns nil when the error [Is] sentinel.
func Ignore(sentinel error) func(error) error) {
	return func(err error) error {
		if Is(err, sentinel) {
			return nil
		}
		return err
	}
}

Functions such as template.Must and regexp.MustCompile may be deprecated in favor of the new try f() handle panic idiom.

try–handle rewritten into existing language

Let's take the example below and rewrite it as equivalent code in the current Go language to show the nuts and bolts of what the new code does. In the below f returns (int, error) and h1 and h2 are func(error) error.

The variable names introduced by the rewrite are unimportant.

This section is not meant to describe the implementation. It is only so you can see how try and handle operate by way of semantically equivalent code in the current language.

func example() (int, error) {
	defer handle h2
	x := try f() handle h1
	return 2*x, nil
}

func example() (_ int, dherr error) { // (1)
	defer func() {
		if dherr != nil {
			dherr = h2(dherr)
		}
	}() // (2)

	x, terr := f() // (3)
	if terr != nil {
		terr = h1(terr)
		return 0, terr
	}

	return 2*x, nil
}

Notes:

  1. First, we name the returned error so that we can access it from our deferred func. As a language feature, defer handle would manage this transparently.
  2. The defer handle expands into a straightforward function that invokes the handler if the error is nil.
  3. The try expands into the basic if err != nil { return ... boilerplate and a tryhandle simply inserts an invocation of the handler after the nil check and before the return.

Functions that do not return an error work largely the same way.

func example2() int {
	defer handle panic
	x := try f()
	return 2*x
}

func example2() int {
	var dherr error // (1)

	defer func() {
		if dherr != nil {
			panic(dherr)
		}
	}() // (2)

	x, terr := f() // (3)
	if terr != nil {
		dherr = terr
		return 0 
	}

	return 2*x
}

Notes:

  1. We need to introduce a local error value to substitute for our named return value in the previous example.
  2. The deferred handler code is essentially the same
  3. The expansion for try is largely similar except we assign to our special error before returning instead of returning the error. If this were a tryhandle we would invoke the handler before this.
@jimmyfrasche jimmyfrasche added error-handling Language & library change proposals that are about error handling. LanguageProposal Issues describing a requested change to the Go language specification. Proposal labels Apr 14, 2025
@gopherbot gopherbot added this to the Proposal milestone Apr 14, 2025
@thepudds
Copy link
Contributor

thepudds commented Apr 14, 2025

In the original try proposal (#32437):

  1. Many thought the try was too hard to see, especially if it could appear in the middle of a complex line, or be nested with multiple try within a single statement, which many thought problematic given it could change control flow.

  2. Many thought it would be too hard under that proposal to do in-place annotation or wrapping of errors, including when coming back to code after a function is first written.

I think your proposal basically addresses those problems.

For Go code today, there is a balance:

  • It seems seasoned gophers can scan error handling at a glance and register its existence, but do so without getting bogged down.
  • On the other hand, when thinking about error handling, a seasoned gopher today usually does not accidently miss seeing error handling because of its current syntactic prominence.

I think that balance today is part of what makes Go error handling work in practice, even though Go does not have an Option type, matching, the ability to force handling of certain errors, and so on.

I think your proposal largely keeps both sides of that balancing act true, and FWIW, I think the placement of the try is an important part of that (including I think people would learn to not be distracted by it).

Finally, in the original try proposal, many did not like how named return parameters were used, but from what I could see, the reaction to that was not as strong compared to how strongly some people felt about items 1. and 2. above. In any event, your proposal seems to address that concern as well.

@jub0bs
Copy link

jub0bs commented Apr 15, 2025

It introduces two keywords, try and handle.

Regardless of the merits/demerits of this proposal, wouldn't the addition of keywords in the language constitute a breaking change? I thought that the Go team's intention was to avoid the need for Go 2.0 at all costs.

Is the plan to gate such changes behind the go directive in go.mod? If so, wouldn't that prevent people who make heavy use of try and handle as identifiers from updating to Go 1.25+?

@thepudds
Copy link
Contributor

Rather than reserved keywords, I wonder if try and handle could be some flavor of predeclared identifiers that can only be used in a certain way, or something along those lines. (For example, iota is a predeclared identifier with limitations, though I don't know if that exact approach would work here).

My initial guess would be it's at least plausible that either this current proposal or a slight modification to it could be made to coexist, for example, in the same file as a current try helper function or an existing imported package using the package name try, etc.

Though maybe not, including there can be a problem of a parsing ambiguity or lookahead requirement.

In short, I don't know if it's actually possible here, but I do know that there are people who are clever about working through how to evolve the spec in a backwards compatible way if it's worthwhile, though perhaps it would require a modification to this proposal.

@mvdan
Copy link
Member

mvdan commented Apr 15, 2025

Personally I prefer the keyword form; try f() is much clearer than e.g. try(f()), as it mimics other keywords which alter the control flow such as return or break.

The bar for adding new keywords should be high, but I don't think it should be out of the question. It can be gated behind the Go language version in go.mod, and existing code can be rewritten with go fix, such as renaming func try to func try_.

@ldemailly
Copy link

ldemailly commented Apr 15, 2025

existing code can be rewritten with go fix, such as renaming func try to func try_.

or the compiler can be made smart enough to see func try() can only be an (old style) identifier (and so is fnPtr := try etc) or would that be slowing down compilation time measurably? [edit: that's kinda what thepudds already said]

@jimmyfrasche
Copy link
Member Author

I think this is a sufficiently good reason to add keywords. It does add to the effort of rolling the feature out, but there's nothing insurmountable there. Perhaps there are other choices for the names that will require fewer rewrites. That can be studied further into the process.

Keywords are not strictly necessary for this proposal to work, however. It could be done with new operators. Something like @f(x) # h and defer #h instead of try f(x) handle h and defer handle h. I considered something like that but I think you'll see why I went with keywords for now.

Regardless, this is of lesser importance than the semantics of the feature.

If the semantics are good then the rest is a paint job.

@DmitriyMV
Copy link
Contributor

If we allow only one defer handle than perhaps we could shorted in to just handle? But then there is a question about what happens with defer handle errHandle; try myErr handle otherHandle?

@paskozdilar
Copy link

paskozdilar commented Apr 16, 2025

Would it be possible for handle to not return, in some special case of errors?
E.g.

func ReadFileDefault(path, def string) error {
    _, err := try os.ReadFile(path) handle func(err error) error {
        if errors.Is(err, os.ErrNotExist) {
            // initialize file and continue
            os.WriteFile(path, []byte(def), 0o644)
            return nil
            // does this return nil from ReadFileDefault, or continue ?
        }
        // ...
    }
    // ...
}

@thepudds
Copy link
Contributor

Hi @paskozdilar, the proposal writeup includes:

There is no way for a handler to stop the function from returning. It may only react.

Personally, I think that is one of the strengths of the proposal, including it could allow you to reason more easily about a function when skimming or when trying to read carefully.

If you are reading some function Foo, you know Foo cannot continue past a try when there's an error.

@jimmyfrasche
Copy link
Member Author

@DmitriyMV

If we allow only one defer handle

I see no reason to limit it to one defer handle and doing so would make a lot of code go from simple to write to hard to write. See CopyFile function in the Examples section. It defers a second handler in the middle of the function that you couldn't set up when the first is deferred and you wouldn't want to even if you could.

perhaps we could shorted in to just handle?

If defer handle is shortened to just handle you could have

try f()
handle h

and was that meant or should that have been try f() handle h? defer handle avoids the situation while making it clear that the defer stack is being used.

@jimmyfrasche
Copy link
Member Author

@paskozdilar what @thepudds said.

But also a handler is basically called like err = h(err) so if h returns nil that's what err is now. It keeps the mental model simple. It's also useful. See the Ignore helper toward the end of the first post.

@earthboundkid
Copy link
Contributor

Bash calls defer handle trap, which seems like a better name to me.

@jimmyfrasche
Copy link
Member Author

Are you saying that it should be three keywords? or that it should be try f() trap t and defer trap?

@earthboundkid
Copy link
Contributor

I don't think there need to be two ways to do it, so I would eliminate the try + handle form and just do:

trap h
f := try os.Open(filename)

If you want a particular handler for a particular line, that's what if already does.

@xiaokentrl
Copy link

Enhance the role of defer, add defer on error

defer on error { ... }


func ConvertAndSave(src, dst string) (err error) {
    defer on error { log.Printf("failed: %v", err) }
    
    input := os.ReadFile(src) ? "read input"
    defer on error { os.Remove(dst) }
    
    data := process(input) ? "process data"
    os.WriteFile(dst, data, 0644) ? "write output"
    return nil
}
data := os.ReadFile("file.txt") ?              // Automatic return of errors (with implicit context)
data := os.ReadFile(path) ? "read config"      // The error message automatically contains ‘read config: ...’


func RetryFetch(url string, retries int) (data []byte) {
    for i := 0; i < retries; i++ {
        data = HttpGet(url) ? |> func(err error) error {
            if IsTimeout(err) && i < retries-1 {
                log.Printf("retrying...")
                return nil 
            }
            return err
        }
        break
    }
    return
}

@jimmyfrasche
Copy link
Member Author

@earthboundkid
Then you can't do var Global = try f() handle panic at the package level or have something like

try f()
try g() handle panic
try h()

for cases where you want to do something a little extra for one call in the middle

@jimmyfrasche
Copy link
Member Author

@xiaokentrl that seems more complicated, more magical, and less powerful. I don't think those are good trade offs or very much related to this proposal. I believe there have been others closer to that but do not recall the issue numbers off hand.

@earthboundkid
Copy link
Contributor

This is legal today FWIW:

func must[T any](v T, err error) T {
	if err != nil {
		panic(err)
	}
	return v
}

func handle(errp *error) {
	if v := recover(); v != nil {
		if recerr, ok := v.(error); ok {
			fmt.Println("handling")
			*errp = recerr
		}
	}
}

func f() (err error) {
	defer handle(&err)
	f := must(os.Open("x"))
	_ = f
	return
}

It would be interesting to have try and trap that work like must and defer handle(&errp) here, but without using panics and named returns.

@thepudds
Copy link
Contributor

thepudds commented Apr 16, 2025

I don't think there need to be two ways to do it, so I would eliminate the try + handle form and just do:

trap h
f := try os.Open(filename)

Hi @earthboundkid, eliminating the try + handle form feels in essence like a different proposal. In addition to what @jimmyfrasche said, it goes against being able to easily do in-place annotation or wrapping of errors, including when coming back to code after a function is first written. (See point 2. in #73376 (comment)).

@jimmyfrasche
Copy link
Member Author

@earthboundkid in a lot of ways what you want to do without panics is basically this proposal! Check out the last section of the first post where I manually desugar the proposed constructs

@xiaokentrl

This comment has been minimized.

@jimmyfrasche
Copy link
Member Author

@xiaokentrl that approach is so different from what is being discussed here that you'd be better off making a separate proposal.

@seh
Copy link
Contributor

seh commented Apr 16, 2025

I don't think there need to be two ways to do it, so I would eliminate the try + handle form and just do:

trap h
f := try os.Open(filename)

In bash, trap names the signals that one wishes to trap (or ERR for any command that exits with a nonzero code), in addition to any commands that should be executed in response. Here, we're we're not saying what to trap explicitly, but rather how to respond to an error being returned, so your proposed change is analogous to trap command ERR in bash, with the ERR elided.

Given that, I don't find "trap" to be an appropriate verb. If we were specifying a curried errors.Is or a similar predicate with which to test the error, I'd like it more.

@jimmyfrasche
Copy link
Member Author

I think having both tryhandle and defer handle is the major innovation in this proposal. Most proposals just have one or the other. But I think having both is important: there are things that you can do with tryhandle that you simply cannot do with defer handle, but, in the majority of code, all you really need is a defer handle or two. Throwing either out to have a single mechanism is a false economy.

@apparentlymart
Copy link

This proposal does seem to neatly reduce the boilerplate of both of the currently-competing conventions in the go ecosystem:

  • try ... handle is the "caller annotates" variation, where the caller annotates the error with information about what it was hoping to achieve by calling the function that failed.
  • defer handle is the "callee annotates" variation, where the callee annotates all of the errors it returns with the same fixed prefix that describes the meaning or effect of the function call that failed.

However, I feel concerned about blessing both of these patterns with a special language feature, because the ambiguity about which to use is already causing confusingly-redundant error messages at the boundaries between packages that prefer the opposite orientation. Just a few hours ago I encountered an error from a Go program that said something to the effect of:

failed to download package from http://example.invalid/something.zip: fetching http://example.invalid/something.zip: Get "http://example.invalid/invalid.zip": dial tcp: lookup example.invalid on 127.0.0.53:53: no such host

Some of the functions in this call chain did the callee-annotates approach and others did the caller-annotates approach, and so it mentions three times that it was trying to retrieve http://example.invalid/something.zip. When I see errors like this in software I control I'm often tempted to just throw away the err.Error() result completely and replace it with something generic, but that's annoying because there is some useful context in this error message... it's just accompanied by a bunch of distracting redundant junk, and it's often hard to know what types can be used with errors.As to extract something useful out of all of this.

One potentially-useful outcome of having a built-in error handling feature in the language could be to finally encode into the language whether "caller annotates" or "callee annotates" is the main idiomatic way to deal with error annotation in most cases, and then hopefully through gradual repair over time we can eventually be free of these unnecessarily-long error messages that typical Go software is prone to generate today.

For that reason, I'd personally prefer to choose either try ... handle or defer handle, rather than having both.

(I know from previous discussions that this question of who should be responsible for annotating an error has strong opinions on both sides, so I'd advise against having yet another iteration of the argument about whether "caller annotates" or "callee annotates" is better in the comments of this issue. I have my own preference of course, but more important to me is that there is one generally-adopted answer, regardless of which it is.)

@jimmyfrasche
Copy link
Member Author

defer handle is what you want most of the time but there are times where you cannot use it and you need tryhandle (package level variables). So, if there's only one, it has to be tryhandle since that covers more cases but now almost all those cases are harder because you have to repeat the handler possibly dozens of times in a single function. You really do need both even though most of the time you're going to be using defer handle.

@jimmyfrasche
Copy link
Member Author

At any rate, I don't think the repetitious onion error message is the result of where in a function the error is annotated. It appears to be the result of a chain of functions each including the same information in their annotations. That does not seem relevant to this class of error handling proposal. Perhaps there needs to be some better way of manipulating error values at runtime so you could tell the inner errors to keep it brief? I dunno. We're getting off topic.

@apparentlymart
Copy link

From reviewing the source code of the program that generated the error I know that at the first two of those three redundant mentions of fetching the URL are caused by the problem I described: the first one was added by the callee of a function and the second one was added by that function itself, presumably because caller and callee were written by people with different expectations.

I'll concede that the third one is caused by the net/http functions themselves also annotating their error messages, and so is not actually a direct example of the caller/callee disagreement I was describing. Perhaps neither the caller or callee of the first frame ought to have included the URL in their error messages and just let the standard library add all of the context in this case, but the general lack of consistent guidance on what context is expected to be added for each call is the general problem that I was trying to draw attention to, and something I'd hope that any language feature focused on error handling would improve on at least a little.

With that said: I'm happy to leave this here. I don't think we need to debate that specific example any further, and I agree that the problem I described has a broader scope than just what was proposed here, so is probably better discussed in another place if anything more needs to be said about it. 🤷

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
error-handling Language & library change proposals that are about error handling. LanguageProposal Issues describing a requested change to the Go language specification. Proposal
Projects
None yet
Development

No branches or pull requests