Skip to content

proposal: Go 2: error handling: try statement with handler #56165

Closed as not planned
@gregwebs

Description

@gregwebs

Author background

  • Experience: 4 years experience writing production Go code. Expert at writing if err != nil. I have forked and created Go error handling libraries.
  • Other language experience: Many of the common languages that are not JVM/CLR (Rust, Haskell, Python, Ruby, TypeScript, Nim, SQL, bash, terraform, Cue, etc)

Related proposals

  • Has this been proposed before? Variations have been proposed, this is discussed in the proposal.
  • Error Handling: Yes

Proposal

Add a new statement try that allows for succinct error handling.

try err, handler

This is translated to

if err != nil {
    return handler(err)
}

Zero values are generated for any return types, so to see this in the context of a function:

func(args...) (rtype1, rtypes..., rtypeN, error) {
    try err, handler
    ...
}

turns into the following (in-lined) code:

func(args...) (rtype1, rtypes..., rtypeN, error) {
    if err != nil {
            return Zero(rtype1), Zeros(rtypes...)..., Zero(rtypeN), handler(err)
    }
    ...
}

The handler argument is optional

try err

This is translated to

if err != nil {
    return err
}

Unlike in previous proposals, try is a statement, not an expression.
It does not return any values.
When a function returns non-error results, an intermediate error variable must be used.

x, err := f()
try err

If an expression returns just an error, it is possible to use try directly on the expression without an intermediate variable.
For the sake of consistency it may be desireable to not allow this form (possibly enforced by linter rather than compiler).

func f() error { ... }
try f()

Discussion Summary

This section summarizes the discussion that took place on the proposal that you don't have to wade through lots of comments. It has been inserted into the original proposal.

The below are points that in theory are easy to resolve by altering the existing proposal:

  • The name try does not capture the operation well, check and returnif are given as possible alternatives.
  • The use of a comma separator is not liked by some because it looks like a multi-value return. with has been given as a possible alternative.
  • Adding a ThenErr error handler function composition is dis-liked by many (it's not clear to me why). An alternative is to not add it.
  • defer try does not match how defer currently takes an expression. The alternative is to not add this.

Below are points that were raised about the benefits and costs of this proposal:

  • There is some interest in generalizing these kind of proposals to work for other zero values in addition to just errors. But this has not been fully thought through. This could make the feature more powerful, and thus more worth it, or it may just be confusing and unworkable.
  • It may be too much work for tooling to adapt to this new statement. Thus this may be a difficult breaking change for some tools to deal with

And of course are value judgements about whether the benefits outweigh the costs. For some Go programmers, using anything except return for returning creates multiple ways to do the same thing, which is unacceptable. Some try to address this by changing the keyword to something like returnif.

Background

Existing proposals to improve Go errors taught us that our solution must provide 2 things:

  • the insertion of a return statement for errors
  • compose error handler functions together before the error is returned

Existing solutions handle the first point well but most have done poorly on the second. With a slight variation on existing error handling proposals, we can provide the second.

Motivation: Go's biggest problem

Recently the Go Developer Survey 2022 Q2 results were released.
Now that Generics have been released, the biggest challenge for Go developers is listed as "Error handling / working with stack traces".

Error handling: missing or poorly implemented in many proposals

This proposals allows the Go programmer to write the exact same code, but more tersely:

f, err := os.Open(filename)
try err

In such a simple case, with no error handler, this transformation may not be very valueable. However, even in relatively simple case, consider if the zero values are verbose:

x, err := f()
if err != nil {
    return MyLargeStructName{} otherpackage.StructName{}, err
}

In the above example, programmers are tempted to return the structs as pointers just so they can return nil rather than obfuscate their code with zero values. After this proposal, they can just write:

x, err := f()
try err

Additionally, there is the case of "handling the error". Often we want to annotate the error with additional information, at least an additional string. Adding this code that modifies the error before it is returned is what I will refer to as adding an "error handler".

The original draft proposal solution used stacked error handlers, but this has difficulties around composition due to the automatic stacking and code readability since the error handler is invoked implicitly. A second proposal was put forth not long after which implemented try as an expression and without any support for (stacked) error handlers. This proposal had extensive discussion that the author attempted to summarize. In my view this proposal was poor because it did not create any affordances for error handling and instead suggested using defer blocks. Defer blocks are a powerful and orthogonal tool that can solve the problem, but for many normal error handling use cases they are clumsy and introduce incidental complexity.

A solution to the error problem should encourage the Go programmer to add error handling code as needed.

Extending existing solutions with function-based error handling

Composing error handlers can be solved by adding a 2nd parameter to try. The second parameter is an errorhandler of type func(error) error or more precisely with generics: type ErrorHandler[E error, F error] func(E) F.

Now we can cleanly write the following code given from the original problem statement:

func CopyFile(src, dst string) error {
    handler := func(err error) error {
            return fmt.Errorf("copy %s %s: %w", src, dst, err)
    }

    r, err := os.Open(src)
    try err, handler
    defer r.Close()

    w, err := os.Create(dst)
    try err, handler.ThenErr(func(err error) error {
            os.Remove(dst) // only if Create fails
            return fmt.Errorf("dir %s: %w", dst, err)
    })
    defer w.Close()

    err = io.Copy(w, r)
    try err, handler
    err = w.Close()
    try err, handler
    return nil
}

ThenErr would be a standard library function for chaining error handlers.
The new example dramatically reduces verbosity. Once the reader understands that try performs an early return of the error, it increases readability and reliability. The increased readability and reliability comes from defining the error handler code in one place to avoid de-duping it in your mind at each usage site.

The error handler parameter is optional. If no error handler is given, the error is returned unaltered, or alternative mental model is that a default error handler is used which is the identity function type ErrorId[E error] func(err E) E { return err }

The CopyFile example is probably a best case for using defer for error handling. This technique can be used with try, but it requires named return variables and a pointer.

// This helper can be used with defer
func handle(err *error, handler func(err error) error) {
    if err == nil {
        return nil
    }
    *err = handler(err)
}

func CopyFile(src, dst string) (err error) {
    defer handle(&err, func(err error) error {
        return fmt.Errorf("copy %s %s: %w", src, dst, err)
    })

    r, err := os.Open(src)
    try err
    defer r.Close()

    w, err := os.Create(dst)
    try err, func(err error) error {
            os.Remove(dst) // only if Create fails
            return fmt.Errorf("dir %s: %w", dst, err)
    }
    defer w.Close()

    err = io.Copy(w, r)
    try err
    err = w.Close()
    try err
    return nil
}

Conclusion

This proposal allows for:

  • the insertion of a return statement for errors
  • composition of error handler functions together before the error is returned

Please keep discussions on this Github issue focused on this proposal rather than hashing out alternative ideas. Almost all the alternatives have been hashed out already.

Provisional

This proposal should be considered for provisional acceptance. The following will need to be well-specified (some are mentioned below in the appendix):

  • Decide how to interact with defer
  • The best name for try - this should be discussed separately after this proposal is provisionally accepted
  • Firm decision as to whether to use lazy error handlers

Appendix: alternative names

I would be happy with try being renamed to anything else. Besides other single words like check, return if has been proposed.
I use try in this proposal because it is the shortest word that has been proposed so far.
This proposal would leave it to the Go maintainers to decide the best name for the word.

Appendix: lazy error handlers

It is tempting to make error handlers lazy.
This way we don't need to bother with making curried handlers.

x, err := f()
try err, fmt.Errorf("f fail: %w", err)

I am sure this will appeal to many as seeming to be Go-like. It would work to do it this way.
This proposal has a preference for the function handler over a lazy handler to reduce defects.
The lazy form requires using an intermediate variable 3 times. It is possible in Go to produce a defect by using the wrong error variable name.

A go program generally only needs 3 supporting standard error handling functions in a curried form.

  • Wrap an error so that it can be unwrapped (%w)
  • Wrap an error so that it cannot be unwrapped (%v)
  • Add a cleanup handler

However, we should consider supporting both a lazy handler and a function handler.

Appendix: special usage with defer

We could explore making the defer and try combination special in that it would accept an error handler function and apply it to the returned error value (if not nil) without requiring a named return value

func CopyFile(src, dst string) error {
    defer try func(err error) error {
        return fmt.Errorf("copy %s %s: %w", src, dst, err)
    }

Appendix: Citations

Appendix: prior art

There are 2 boilerplate reductions from this proposal:

  • avoiding if err != nil { and using 1 line
  • avoiding generating zero values for the return statement

I believe the latter is well addressed by this proposal that automatically generates zero values from return ..., err. It is unfortunate that no action has been taken on that existing proposal. If this proposal were accepted, I think that in any place where one might use return ..., err one could just use try. If return ..., err were already possible I think try might not add enough value.

This proposal is still open and is equivalent to this proposal without the handler argument. It is suggested to add error handling via handler functions that already have an if err == nil { return nil } guard. But then using handlers requires reading code and looking at the calling function call to understand how it works and to ensure that it works properly.

There have been proposals for dispatching on or after an error value assignment. These are quite similar to this proposal but suffer from being tied to assignment.

This proposal is different, but notes that it adds a with keyword for the handler. We could do that for this proposal, but it seems preferable to only reserve one keyword and use a comma.

I made a similar proposal in which try was an expression rather than a statement.

Appendix: generic enumerations

Now that Go has Generics, we might hope for this to get extended to enumerations and have a Result type like Rust has. I believe that when that day comes we can adapt try to work on that Result type as well.

Appendix: implementation

The try library implements this proposal as a library function. However, it has several shortcomings as a library function that can only be resolved by building features into the Go language itself.

Appendix: code coverage

There are concerns about code coverage. It may be a significant burden for line-oriented code coverage tools to figure out how to tell users if the error paths are getting exercised. I would hate for a helpful tool to hold back back language progress: it is worth it for the community to undertake the effort to have code coverage tools that can determine whether the error path of try is getting exercised.

Appendix: examples

import (
    "fmt"
)

// This helper should be defined in the fmt package
func Handlew(format string, args ...any) func(error) error {
	return func(err error) error {
		args = append(args, err)
		return fmt.Errorf(format+": %w", args...)
	}
}

// This helper should be defined in the fmt package
func Handlef(format string, args ...any) func(error) error {
	return func(err error) error {
		args = append(args, err)
		return fmt.Errorf(format+": %v", args...)
	}
}

func valAndError() (int, error) {
    return 1, fmt.Errorf("make error")
}

func newGo() (int, error) {
    x, err := valAndError()
    try err

    // Common formatting functions will already be provided
    i := 2
    x, err = valAndError()
    try err, Handlew("custom Error %d", i)

    // Using a custom error type
    // For convenience the error type can expose a method to set the error
    x, err = valAndError()
    try err, TheErrorAsHandler(i)
}

type TheError struct{
    num int
    err error
}

func (t TheError) Error() String {
    return fmt.Sprintf("theError %d %v", t.num, t.err)
}

func TheErrorAsHandler(num int) func(err) TheError {
    return func(err error) TheError {
        return theError{ num: i, err: err }
    }
}

Appendix: real world code base examples

I did some automated language transforms to use try on the golang codebase. This is easily automated now with Semgrep rules and a little shell script so I could apply this to any code base. Unfortunately it is only examples of using try without an error handler. Try with an error handler is much more difficult to automate.

Costs

  • Would this change make Go easier or harder to learn, and why?

Harder to learn the language spec because users must learn a new keyword. However, when verbose error handling code is removed, beginners will be able to read and evaluate Go code more quickly and learn Go faster.

  • **What is the cost of this proposal? **

The costs are discussed in detail elsewhere

  • understanding a new keyword
  • requiring go fix for upgrading
  • code coverage tool upgrading
  • How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected?

All, but those that re-use Go libraries may just need to upgrade their library usage?
I think these tools would then have less source code to analyze and thus run more quickly.
Linters that check that all errors are handled currently use a lot of CPU.
These could be simplified or even removed entirely.

  • What is the compile time cost?

Without handlers I would think it could be reduced because the compiler can avoid evaluating code that previously would have been hand-written. It will reduce the error linting time more significantly (see above) for those of us that run linters right after compilation since checking for proper error handling will be easier.

  • What is the run time cost?

I think this should be equivalent. The success and error path should be the same. However, having better error handling abilities will encourage Go programs to better annotate their errors. But the error path should not be a performance concern.

  • Can you describe a possible implementation?

I started a branch that gives some idea of some of the changes required, but keep in mind that it is incomplete and already making implementation mistakes.

  • Do you have a prototype? (This is not required.)

This can be roughly implemented as a library, done here.
However, it is limited and that can only be solved with compiler modifications.
Internally it uses panic/recover.

  • This is slow when there is an error (but fortunately does not affect the success path)
  • It requires a defer at the top of the function and using a pointer to a named return variable for the error

Metadata

Metadata

Assignees

No one assigned

    Labels

    FrozenDueToAgeLanguageChangeSuggested changes to the Go languageProposalerror-handlingLanguage & library change proposals that are about error handling.v2An incompatible library change

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions