Skip to content

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

Closed

Description

@jimmyfrasche

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 try–handle 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 try–handle 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 try–handle we would invoke the handler before this.

Activity

  1. added
    error-handlingLanguage & library change proposals that are about error handling.
    LanguageProposalIssues describing a requested change to the Go language specification.
    on Apr 14, 2025
  2. added this to the Proposal milestone on Apr 14, 2025
  3. thepudds commented on Apr 14, 2025

    @thepudds
    Member

    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.

  4. jub0bs commented on Apr 15, 2025

    @jub0bs
    Contributor

    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+?

  5. thepudds commented on Apr 15, 2025

    @thepudds
    Member

    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.

  6. mvdan commented on Apr 15, 2025

    @mvdan
    Member

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

  7. ldemailly commented on Apr 15, 2025

    @ldemailly

    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]

  8. jimmyfrasche commented on Apr 15, 2025

    @jimmyfrasche
    MemberAuthor

    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.

  9. DmitriyMV commented on Apr 16, 2025

    @DmitriyMV
    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?

  10. paskozdilar commented on Apr 16, 2025

    @paskozdilar

    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 ?
            }
            // ...
        }
        // ...
    }
  11. thepudds commented on Apr 16, 2025

    @thepudds
    Member

    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.

  12. jimmyfrasche commented on Apr 16, 2025

    @jimmyfrasche
    MemberAuthor

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

  13. 10 remaining items

  14. xiaokentrl commented on Apr 16, 2025

    @xiaokentrl
  15. jimmyfrasche commented on Apr 16, 2025

    @jimmyfrasche
    MemberAuthor

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

  16. seh commented on Apr 16, 2025

    @seh
    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)
    

    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.

  17. jimmyfrasche commented on Apr 16, 2025

    @jimmyfrasche
    MemberAuthor

    I think having both try–handle 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 try–handle 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.

  18. apparentlymart commented on Apr 18, 2025

    @apparentlymart

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

  19. jimmyfrasche commented on Apr 18, 2025

    @jimmyfrasche
    MemberAuthor

    defer handle is what you want most of the time but there are times where you cannot use it and you need try–handle (package level variables). So, if there's only one, it has to be try–handle 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.

  20. jimmyfrasche commented on Apr 18, 2025

    @jimmyfrasche
    MemberAuthor

    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.

  21. apparentlymart commented on Apr 18, 2025

    @apparentlymart

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

  22. griesemer commented on May 22, 2025

    @griesemer
    Contributor

    Am I missing something or is try essentially check as 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 handle functionality admittedly adds an interesting twist. But—despite the claim that leaving handle away would be a false economy—augmenting an error "on the way out" with defer is still possible as explained in the try proposal, even without handle, isn't it? For instance, the example

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

    could be written as

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

    which seems clearer because we see what's going into h and there's no need to "learn about handle".

    The main benefit of handle arises when used in the same statement as try. Without handle one will have to resort to the standard if err != nil.

    This was essentially the point of the try statement. A problem with try was that it could be used in nested expressions, which was a mistake.

    Regarding try being 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 of try inside 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 try as proposed here is used with a global (package-level) variable declaration?

  23. jimmyfrasche commented on May 23, 2025

    @jimmyfrasche
    MemberAuthor

    Am I missing something or is try essentially check as presented in Error Handling — Draft Design in 2018?

    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.

    Finally, a question: what happens when try as proposed here is used with a global (package-level) variable declaration?

    This is at the end of the section about functions that do not return an error.

    Here, there must be a handle and the handler must not have a return. So you could write

    package p
    var G = try f() handle panic

    but you could not just write var G = try f().

    Without the try–handle form you could only use try in function bodies.

    For functions that do not return an error, you can only use try by itself if you already have such a handler deferred. This forces an explicit handler when there's not something obvious to do.

    augmenting an error "on the way out" with defer is still possible as explained in the try proposal, even without handle, isn't it?

    defer handle isn'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:

    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()
    }

    try–handle covers everything, including package scoped variables which defer handle cannot cover. My point upthread was that if there can be only one it must be try–handle but then you run into the problem of having to repeat the same code over and over and over instead of just sticking a defer handle at the top so you need both even though 90% of the time in practice you'd just use defer handle and solo try.

    [example with named error]

    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–handle form you would either need to repeat the error handler for each try or put your own hooks in to do the same thing:

    func noRet() {
    	var err error
    	defer h(&err)
    	err = f()
    	if err != nil {
    		return
    	}
    	// etc.
    }
  24. griesemer commented on Jun 4, 2025

    @griesemer
    Contributor

    Closed per the decision outlined in [ On | No ] syntactic support for error handling.

  25. magical commented on Jun 4, 2025

    @magical
    Contributor

    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.

  26. locked and limited conversation to collaborators on Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    FrozenDueToAgeLanguageProposalIssues describing a requested change to the Go language specification.Proposalerror-handlingLanguage & library change proposals that are about error handling.

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions