Repository navigation
proposal: spec: try-handle keywords for reducing common error handling boilerplate #73376
Description
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
- one that takes an error and returns an error:
err = h(err) - one that takes an error and returns nothing:
h(err) - 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 panicdefer handle log.Fataldefer handle t.Fatal
This same logic also allows package level variables to use try–handle as long as the handler does not have a return, for example:
var prog = try compile(src) handle panictry, 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:
- First, we name the returned error so that we can access it from our deferred func. As a language feature,
defer handlewould manage this transparently. - The
defer handleexpands into a straightforward function that invokes the handler if the error isnil. - The
tryexpands into the basicif err != nil { return... boilerplate and atry–handlesimply inserts an invocation of the handler after thenilcheck and before thereturn.
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:
- We need to introduce a local error value to substitute for our named return value in the previous example.
- The deferred handler code is essentially the same
- The expansion for
tryis largely similar except we assign to our special error before returning instead of returning the error. If this were atry–handlewe would invoke the handler before this.
Activity
gabyhelp commentedon Apr 14, 2025
Related Issues
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
thepudds commentedon Apr 14, 2025
In the original
tryproposal (#32437):Many thought the
trywas too hard to see, especially if it could appear in the middle of a complex line, or be nested with multipletrywithin a single statement, which many thought problematic given it could change control flow.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:
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
tryis an important part of that (including I think people would learn to not be distracted by it).Finally, in the original
tryproposal, 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 commentedon Apr 15, 2025
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
godirective ingo.mod? If so, wouldn't that prevent people who make heavy use oftryandhandleas identifiers from updating to Go 1.25+?thepudds commentedon Apr 15, 2025
Rather than reserved keywords, I wonder if
tryandhandlecould 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
tryhelper function or an existing imported package using the package nametry, 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 commentedon 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 asreturnorbreak.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 withgo fix, such as renamingfunc trytofunc try_.ldemailly commentedon Apr 15, 2025
or the compiler can be made smart enough to see
func try()can only be an (old style) identifier (and so isfnPtr := tryetc) or would that be slowing down compilation time measurably? [edit: that's kinda what thepudds already said]jimmyfrasche commentedon Apr 15, 2025
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) # handdefer #hinstead oftry f(x) handle handdefer 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 commentedon Apr 16, 2025
If we allow only one
defer handlethan perhaps we could shorted in to justhandle? But then there is a question about what happens withdefer handle errHandle; try myErr handle otherHandle?paskozdilar commentedon Apr 16, 2025
Would it be possible for
handleto not return, in some special case of errors?E.g.
thepudds commentedon Apr 16, 2025
Hi @paskozdilar, the proposal writeup includes:
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 knowFoocannot continue past atrywhen there's an error.jimmyfrasche commentedon Apr 16, 2025
@DmitriyMV
I see no reason to limit it to one
defer handleand doing so would make a lot of code go from simple to write to hard to write. SeeCopyFilefunction 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.If
defer handleis shortened to justhandleyou could haveand was that meant or should that have been
try f() handle h?defer handleavoids the situation while making it clear that the defer stack is being used.10 remaining items
xiaokentrl commentedon Apr 16, 2025
jimmyfrasche commentedon Apr 16, 2025
@xiaokentrl that approach is so different from what is being discussed here that you'd be better off making a separate proposal.
seh commentedon Apr 16, 2025
In bash,
trapnames the signals that one wishes to trap (orERRfor 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 totrap command ERRin bash, with theERRelided.Given that, I don't find "trap" to be an appropriate verb. If we were specifying a curried
errors.Isor a similar predicate with which to test theerror, I'd like it more.jimmyfrasche commentedon Apr 16, 2025
I think having both
try–handleanddefer handleis 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 withtry–handlethat you simply cannot do withdefer handle, but, in the majority of code, all you really need is adefer handleor two. Throwing either out to have a single mechanism is a false economy.apparentlymart commentedon Apr 18, 2025
This proposal does seem to neatly reduce the boilerplate of both of the currently-competing conventions in the go ecosystem:
try ... handleis 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 handleis 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:
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 theerr.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 witherrors.Asto 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 ... handleordefer 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 commentedon Apr 18, 2025
defer handleis what you want most of the time but there are times where you cannot use it and you needtry–handle(package level variables). So, if there's only one, it has to betry–handlesince 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 usingdefer handle.jimmyfrasche commentedon Apr 18, 2025
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 commentedon Apr 18, 2025
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/httpfunctions 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. 🤷
griesemer commentedon May 22, 2025
Am I missing something or is
tryessentiallycheckas presented in Error Handling — Draft Design in 2018? You mention that draft's influence on this proposal, just trying to understand the difference, if any).The specific
handlefunctionality admittedly adds an interesting twist. But—despite the claim that leavinghandleaway would be a false economy—augmenting an error "on the way out" withdeferis still possible as explained in the try proposal, even withouthandle, isn't it? For instance, the examplecould be written as
which seems clearer because we see what's going into
hand there's no need to "learn abouthandle".The main benefit of
handlearises when used in the same statement astry. Withouthandleone will have to resort to the standardif err != nil.This was essentially the point of the
trystatement. A problem withtrywas that it could be used in nested expressions, which was a mistake.Regarding
trybeing a keyword or not: yes, we could make it a keyword and make that dependent on the language version. We could also allow it before that language version as a "pseudo keyword": if the first use oftryinside a function happens to be in error handling context, it acts as that keyword; if the first use is say as the name of a variable, it cannot be used for error handling afterwards (in that function). This could be made to work if so desired.Finally, a question: what happens when
tryas proposed here is used with a global (package-level) variable declaration?jimmyfrasche commentedon May 23, 2025
Essentially, yes. Not much is needed other than a "replace boilerplate" button which is why most proposals are just that.
The rest of the replies are out of order.
This is at the end of the section about functions that do not return an error.
Here, there must be a
handleand the handler must not have a return. So you could writebut you could not just write
var G = try f().Without the
try–handleform you could only usetryin function bodies.For functions that do not return an error, you can only use
tryby itself if you already have such a handler deferred. This forces an explicit handler when there's not something obvious to do.defer handleisn't needed in any theoretical sense for functions that return errors. It's purely practical though it's benefits don't really show up in small examples.If there were only
try–handle, this example from the first post would need to repeat a lot of code:try–handlecovers everything, including package scoped variables whichdefer handlecannot cover. My point upthread was that if there can be only one it must betry–handlebut then you run into the problem of having to repeat the same code over and over and over instead of just sticking adefer handleat the top so you need both even though 90% of the time in practice you'd just usedefer handleand solotry.Yes, that is semantically equivalent in functions that return an error.
However, it doesn't work in functions that do not return an error. If there were only the
try–handleform you would either need to repeat the error handler for eachtryor put your own hooks in to do the same thing:griesemer commentedon Jun 4, 2025
Closed per the decision outlined in [ On | No ] syntactic support for error handling.
magical commentedon Jun 4, 2025
I hope this one (or something along the same lines) gets revisited someday, as it's the first error handling proposal I've liked since #32437. In particular, i think jimmyfrasche's observation in #73376 (comment) about supporting handlers at both the function level (via defer) and the statement level is important to consider in any future designs.