go: proposal: Go 2: Error handling that is compact and composeable

Author background

  • Experience: 4 years experience writing Go code. Expert at writing if err != nil. I have forked and created Go error handling libraries.
  • Other language experience: I like to say I have used most most 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? Yes, the differences with existing proposal is extensively discussed in the proposal.
  • Error Handling: Yes
  • Generics: No

Proposal

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

I believe existing solutions handle the first point well but have failed on the second. With a slight variation on existing error handling proposals, we can provide the second.

Additionally, adding a compiler-recognized way of returning errors creates the opportunity to add low-overhead error tracing and thus dramatically improve debugging.

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

Exising Go code

A few years ago, Go developers described the issue with error handling and proposed a solution. As a short review, existing Go code looks like this:

f, err := os.Open(filename)
if err != nil {
        return …, err  // zero values for other results, if any
}

Existing solutions are a great starting point

New proposals allow the Go programmer to write the exact same code, but more tersely:

f := try os.Open(filename)

Where try is a new keyword or builtin that can be though of like a macro that generates the previous Go code with an early return of the error. The problem with this example is that it is trivial. 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 simply implemented try as above without any support for (stacked) error handlers. This proposal had extensive discussion that the author attempted to summarize. I believe the main problem with that second proposal is that it did not create any new affordances for error handling and instead suggested using defer blocks, which are clumsy to use with error handling.

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.

func(args...) (returnTypes..., error) {
        x1, x2, … xN = try f(), handler
        ...
}

turns into the following (in-lined) code:

func(args...) (rtype1, ... rtypeN, error) { // rtype1, ... rtypeN are the return types
        x1, … xN, errTry := f() // errTry is a new variable that is invisible or uniquely named
        if errTry != nil {
                return reflect.Zero(rtype1), ..., reflect.Zero(rtypeN), handler(errTry)
        }
        ...
}

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 := try os.Open(src), handler
        defer r.Close()

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

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

ThenErr would be just 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 }

Supporting quick string annotation

We can support an error handler in the form of a format string.

x = try os.Create(), "os.Create annotation %w"
// The format string is translated to:
func(err error) error { return fmt.Errorf("os.Create annotation %w", err) }

The format string would be required to have %w or %v that the error will be applied to. It would also be required to be a string literal: if string re-use is desired, an error handling function can be used. Since this proposal just uses functions, there may be a way to try to support this use case with normal functions, something like:

func wrapErr(format string, v ...interface{}) func(error) error {
    return func(e error) error {
        v = append(v, e)
        return fmt.Errorf(format + ": %w", ...v)
    }
}

An issue with the function helper approach is the potential for extra overhead: try would need to be able to lazily invoke such an error handler generator.

Try an error directly

Sometimes the right-hand side is large. It may be preferable in some cases to assign the error value and then try it with an annotation.

x, err := valueAndError()
try err, fmt.ErrorHandler("valueAndError %w") // where ErrorHandler returns an error handler function
// or with direct support for a format string
try err, "valueAndError %w"

Similar to today, this style will require the usage of a linter to ensure that the error is checked, and it pollutes the existing scope with a variable that otherwise isn’t needed.

Supporting prefix and suffix handlers

It is possible to support error handlers in the prefix position as well. Although this is sometimes convenient, a Go programmer knows that it isn’t always better to have multiple ways of doing the same thing- this might be a bad idea.

x = try "valueAndError annotation %w", valueAndError()
x = try handler, valueAndError()

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.

Appendix: Citations

Appendix: keyword or builtin

I personally have a preference for making try a keyword, but I would be happy as well it were a builtin. The difference in new code is just a pair of parenetheses. The builtin approach does have the advantage of avoiding breaking existing code that uses try as a variable name, which does occur in the Go compiler code base. However, go fix should be able to perform automatic renames to allow for smoother upgrades.

Appendix: prior art

The err2 library implements try as a library function! Error handlers however, are created with defer: this disconnects the error handlers from their actual error. So this library requires code review to ensure that errors are actually being handled correctly or special linter rules that understand the library.

I forked err2 to make it more similar to this proposal. I am calling the result err3.

There have been proposals for dispatching on or after an error value assignment. These are quite similar to this proposal but suffer from requiring assignment and not being usable as an expression. This proposal is still open and may be the clearest one. Other closed proposals:

There has been a proposal for a postfix catch, but it also includes an error assignment on the LHS.

There was a proposal for a postfix handle that used a prefix try and an implicit `err variable. And other proposals for a postfix error handler:

There has been a proposal similar to this one with what I would consider the right idea. Unfortunately the proposal was very light on detail and otherwise rushed with mistakes in the examples given: https://github.com/golang/go/issues/49091. It had a different syntax: instead of a comma, it used a with keyword. I would be fine with introducing a second keyword, but it does cause more backwards compatibility issues.

I made a similar proposal with a different syntax before there was a process for submitting proposals for changes to the language. The github issue was closed suggesting that I create a blog entry instead. That proposal confused many initial readers by showing problems with existing proposals in code instead of just creating a new proposal (it was later edited). Since then, the additional concept of format strings and error traces has been added (in addition to using try instead of ?).

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 just as Rust has its ? operator.

Appendix: Learning from other languages

The try proposed here is very similar to Zig and Swift.

In addition to Swift using try similar to this proposal, Swift also adds try! (panic) and try? (return nil) variants which Go could consider doing adding in the future as well.

Zig has try and also a second keyword catch that is the same as this proposal. try is used with no error handler and catch is for adding an error handler. I avoid this approach in favor of a single try keyword with a 2nd parameter to avoid introducing an additional keyword. Additionally, the catch keyword in Zig comes with a special syntax for anonymous functions. I believe it is best to use existing Golang syntax for anonymous functions (Zig has no such construct). Having a terser syntax for anonymous functions would be great, but I believe it should be universally applicable. I do think it is possible to allow error handlers to not require type annotations.

It could make sense to use a catch postfix like Zig instead of a try prefix, just as Rust uses a ? as a postfix operator. Personally I would be fine with this option (either catch or ?). I think though that when a line is very long it is useful to see the try at the beginning instead of hunting for it at the end.

Zig is very Go-like in its philosophy and could be a great source of ideas of language evolution. It has an error tracing feature that would solve a major pain point in Go error handling: tracing errors. Also an interesting feature called Error Sets that Go could evaluate. Outside of error handling, but related to a common source of crashes in Go, Zig’s implementation of Optionals is something that Go should evaluate as well.

Appendix: returning a type other than error

In Zig, catch allows the return of a type other than an error, for returning a default value in case of an error.

const number = parseU64(str, 10) catch 13;

My feeling is that this use case is not common enough to justify support for it. Most of the time I would want to at least log something before discarding an error.

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 try is getting exercised.

Appendix: in-lining try

This issue is addressed in a comment reply.

Appendix: examples

import (
        "fmt"
)

func existingGo (int, error) {
        // The existing way of returning errors in Go:
        // 3 additional lines to return an error.
        // If we need to do more than just return the error, this is a pretty good system.
        // However, in the simple case of just returning the error, it makes for a lot of ceremony.
        x, err := valAndError()
        if err != nil {
                return 0, nil
        }
        return x + 1
}

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

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

        // Add a handler inline, by itself not much better than the existing way
        x = try valAndError(), func(err error) error {
                return fmt.Errorf("valAndError %w", err)
        }

        // declare and compose handlers separately
        handler := func(err error) error {
                return fmt.Errorf("valAndError %w", err)
        }

        // handler function annotation
        x = try valAndError(), handler

        handler2 := func(err error) error {
                return fmt.Errorf("part 2 %w", err)
        }

        // compose handler function annotations
        // requires a ThenErr library function
        x = try valAndError(), handler.ThenErr(handler2)

        // We can support putting a handler on either side.
        x = try handler, valAndError()

        // The common case wraps an error with a string annotation.
        // So we can support giving a format string as the handler.
        // Wrap an error with a format string. %w or %v is required.
        x = try valAndError(), "valAndError %w"

        // We can support putting it on either side.
        x = try "valAndError %w", valAndError()

        // Try can also be used directly on an error.
        x, err = valAndError()
        try errors.Wrap(err, "valueAndError)"
        // However, that has overhead that using a builtin format string argument could eliminate
        try err, "valueAndError %w"

       // Format string annotations with additional values should use a handler
        i := 2
        x = try valAndError(), func(err error) error {
                return fmt.Sprintf("custom Error %d %w", i, err)
        }

        // Using a custom error type
        // For convenience the error type can define a method for wrapping the error.
        x = try valAndError(), theError{ num: i }.Wrap
}

type theError struct{
        num int
        err error
}

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

func (theError) Wrap(err error) theError {
        theError.err = err
        return theError
}

func (theError) Unwrap() theError {
        return theError.err
}

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 if we stick with using a keyword instead of a builtin
  • 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. It will mostly remove the need for linters that check for proper error handling.

  • What is the compile time cost?

Without handlers I would think it could be reduced because the compiler can generate code that previously would have been hand-written- the generated code does can be optimized in some ways, for example by not needing to be typechecked. It will reduce the error linting time (see above) for those of us that run linters right after compilation since checking for proper error handling will be easier. Supporting format strings and validating them to contain %w or %v at compile time would have some cost, but we could forego that validation. I am not familiar with the compile time costs of using functions for error handling.

  • What is the run time cost?

None without handlers. The potential cost of supporting format strings is discussed in the proposal. I would think that the overhead of the error handlers can be removed by in-lining the functions? Normally once a function is returning an error, runtime performance is not a concern, so the most important thing is reducing the runtime cost of the happy path. It seems like the happy path should be undisturbed if handlers associated with the error path can be evaluated lazily.

  • 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 mostly implemented as a library, this is done here. However, it has several limitations that can only be solved with compiler modifications.

  • It uses panic/recover which is slow and requires a defer at the top of the function and using a pointer to a named return variable for the error
  • The Check api is okay, but the Try api is awkward.

Try(f(), handler) with no return is fine but with a return things get awkward due to how Go’s multiple return values are not programmable and generic varags are not available. The result is : x := Try1(f())(handler).

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 24
  • Comments: 35 (7 by maintainers)

Most upvoted comments

The biggest concern about the earlier try proposal was the unexpected flow of control change in the middle of an expression. That is try f1() + try f2() can return in the middle, a potential return signaled only by the presence of try. This proposal seems to have the same issue.

@ianlancetaylor I tried to follow the previous discussions and address all relevant points. This actually caught me off-guard, so thanks for bringing it up. At least, I read the summaries and this wasn’t explicitly mentioned as one of the main concerns. I do see now that one of the summaries linked to a discussion that contained this as part of the text of the #2 point.

I think this concern is a red herring, let me explain why.

Discomfort with inlining try is quite similar to discomfort with reading lines with nested function calls. What happened is that someone was quite comfortable with inlining try (and probably nesting function calls) and created examples of transforming existing Go code and maximalized inlining of try. For example the following was given:

        mimeHeader, err := tp.ReadMIMEHeader()
        if err != nil {
                return nil, err
        }
        req.Header = Header(mimeHeader)

transformed to:

        req.Header = Header(try(tp.ReadMIMEHeader())

However, this is just gratuitous inling- a direct translation is:

        mimeHeader := try tp.ReadMIMEHeader()
        req.Header = Header(mimeHeader)

That is, try does not create any new function nesting issues: users that like function nesting create them. It is true that with try there will be more opportunities for nesting function calls for those that want to. In many cases the biggest blocker of direct function chaining now is having to handle errors in between function calls. With a smooth error handling option in place there are more opportunities for chaining function calls. This is actually a good sign: it indicates that Go code is easier to compose. The community will have to figure out how to best deal with easier function composition, but making function composition readable is not actually a new problem for Go code.

Today the problem of nesting function calls is an issue that is handled by not nesting function calls when it impairs readability. When it improves readability, function calls are placed on separate lines and an intermediate variable is declared. try won’t force anyone to stop doing this.

In summary, this is just an issue for a style guide to mention if it does not already. I would be happy to create some linter rules to warn users about gratuitous function chaining, particularly in the context of using try, if existing rules don’t work well enough.

The subsequent discussion included the possibility of making try be allowed only in some specific contexts: normal assignment, channel writes, and as the first token in a statement.

try EXPR
foo := try EXPR
ch <- try EXPR

Would that be a sufficient constraint to ensure that the control flow implied by this feature is not buried inside complex expressions?

(I don’t really have strong feelings either way about this variant of the proposal, so I’m raising it only in the hope of moving discussion forward towards something that might qualify as “new information”, and not as a vote for or against it.)

First of all, I don’t think that go fix is a valid way to avoid the Go 1 Compatibility Guarantee. The guarantee is that existing working code will simply continue to work. Users shouldn’t need to run go fix, and, worse expect all of their dependencies to run go fix, just to keep things working, especially not when there are other solutions that don’t have that issue. The problem would be mitigated to a fair extent by the go directive in go.mod, but if a solution can avoid the problem in the first place, I think that’s better.

From what I’ve seen, most issues with error handling seem to revolve around an inability to apply DRY to error handling mostly itself stemming from an inability to return from the calling function from an inner function easily. A lot of languages avoid this by using an exception system that, much like panic(), causes the entire stack to return continuously until a catch, or a recover() in the case of a panic(), is encountered. This has massive issues, but it does solve that particular problem.

You mention that try is similar to a macro, and I wonder if the problem would simply disappear on its own if Go had its own hygenic macro system? A macro can return from the calling function, since it’s not really ‘called’, and regular usage of it to remove the error return from a function would result in effectively the same thing as an exception system but without all of the hidden magic. There could be a series of standard error-handling macros such as try!() (Yields all but its last argument unless the final argument was a non-nil error in which case it would return from the caller with that error. Other returns would need to be possible via the macro system.) and explain!() (Same thing as try!() but automatically wraps non-nil errors using fmt.Errorf().) and maybe even catch!() (Does what your Zig example from above does.), and then if someone needed repeated custom error handling, they could just write their own. Complicated cases could just keep using manual ifs the way existing code does. Introducing macros and using them for error handling would also avoid the backwards compatibility problem of a keyword.

What if try was only possible at the outermost part of an expression? In other words, it would only be available as the top of a lone expression or the first thing on the right-hand side of an assignment or channel send? However, if a statement contained a try expression, the expression would also be able to use some indicator inside of the expression to pass inner parts back up. That way the presence of try would always be prominent, making it easy to see that an expression might return.

I’m not a huge fan of this particular syntax, but here’s a few possible examples of legal and illegal statements:

x := try { f1() } // Legal.
x := f1() + try { f2() } // Illegal.
x := try { f1() } + try { f2() } // Illegal.
x := try { f1() + f2() } // Legal.
x := try { check f1() + check f2() } // Legal.

This might just be too confusing, though.

The biggest concern about the earlier try proposal was the unexpected flow of control change in the middle of an expression. That is try f1() + try f2() can return in the middle, a potential return signaled only by the presence of try. This proposal seems to have the same issue.

In similar proposals the concern was raised that by “sandwiching” the function call with a keyword (try), and a handler function, it detracts from the readability of the program, ie:

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

makes the os.Create call a bit harder to see. One thought to improve this would be to make the try keyword implicit if a handler is provided such that the above becomes

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

However that would have some syntax collision with multiple assignments, which would necessitate the need for a syntax change. A lot of symbols/keywords would work, but I’ll use the @ symbol for the sake of the argument, so we now have:

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

It does seem possible in principle for the compiler to treat functions where the last return value is of type error as special,

Would that mean that something like errors.New() or fmt.Errorf() would be fallible?