Skip to content

proposal: Go 2: Error handling that is compact and composeable #55026

Closed as not planned
@gregwebs

Description

@gregwebs

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions