Description
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
- one that takes an error and returns an error:
err = h(err)
- one that takes an error and returns nothing:
h(err)
- 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:
- 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. - The
defer handle
expands into a straightforward function that invokes the handler if the error isnil
. - The
try
expands into the basicif err != nil { return
... boilerplate and atry
–handle
simply inserts an invocation of the handler after thenil
check and before thereturn
.
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:
- We need to introduce a local error value to substitute for our named return value in the previous example.
- The deferred handler code is essentially the same
- The expansion for
try
is largely similar except we assign to our special error before returning instead of returning the error. If this were atry
–handle
we would invoke the handler before this.