Skip to content

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

Closed
@jimmyfrasche

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    LanguageProposalIssues 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