Skip to content

Proposal: Go2: Drying up Error Handling in Go #36284

Closed
@ghost

Description

Introduction

We are still experimenting with possible ways to simplify error handling in future releases of Go. I believe that most frustration comes from the repeating of simple error cases, and that we can introduce a simple syntax to help remove this tedious code. The new syntax is inspired by existing Go conventions, will not break existing code, and should feel familiar to the community.

The Proposal

I see there are 4 main cases of errors in Go that we handle as developers.

  1. Ignore the error and continue function execution
  2. Halt function execution and return the error as is
  3. Halt the function execution and return the error via a handler func, or an error wrapper
  4. Handle the error gracefully and continue execution of the function

Case 1

We already have special syntax for case 1. There are no changes here.

// Ignore the error and continue
val, _ := foo()

Case 2

Inspired by the _ operator, I propose a similar syntax for returning errors if they exist.

// If err, halt function and return err
val, ^ := foo()

Case 3

For case 3, it is important that we allow developers to wrap their errors. In many cases, the wrapper or error handler is simple enough to factor into an external function. We must be mindful of which handler is being used for the error, but we still want to clean up the if statement that hangs out below the source of the error. For this case, I'm proposing we use existing channel syntax to register an error handler with the ^ operator, and return the error through the handler.

This pattern is inspired by how we typically remove else statements from our code by beginning with the else case and checking the if case afterward :

^ <- handleErr // Set error handler to func handleErr(err Error) Error
val, ^ := foo() // If err, halt function and return result of the registered handler

^ <- nil // Set the error handler to No error handler (default)
val, ^ := bar() // If err, halt function and return err as is

As you can see above, registering a handler will persist until a new handler is registered. This is necessary to eliminate repeating ourselves if we have a logger that is used for most errors.

Case 4

This final case is our catch all. It allows developers to gracefully handle their errors and provides backward compatibility with the existing convention. There are no changes here.

// If err, handle and continue gracefully
val, err := foo()
if err != nil {
    handle(err)
}

Dependency Injection

One issue that is immediately obvious is the lack of additional arguments in the error handler. To inject additional dependencies, we would have to produce the handler as the result of a closure or use a package such as the popular wire package. I think that using a Handler constructor for dependency injection would be elegant enough to inline as in the following

^ <- makeHandler(dep1, dep2)
val, ^ := foo()

Restricting the Operator to Errors

Because errors are just values, there's really no reason that this new ^ operator couldn't be used for types that are not errors. It is a "Return val if exists" operator and it accepts a single function on a channel that acts as the error handler.

It's not clear to me that using this operator for any value other than errors would be beneficial in the way that using _ is. I recognize that it could be easy to miss in verbose code, and for this reason, I would recommend it be restricted to types that implement the Error interface. In restricting the operator to only error types, we would also need to restrict the handler func to a handler interface which would be the following :

interface Handler {
    Handle(err error) error
}

Multiple Return Values (Added in edit)

If a non error return value has not been initialized, it would be returned as the default "zero" value as is the current convention. If it has been initialized, it would be returned with its current value which is the case for named return values.

func example() (int, error) {
    ^ = foo() // If err, returns 0, err    
    return 3, nil
}
func example() (i int, e error) {
    i = 3
    ^ = foo() // If err, returns 3, err
    return 5, nil
}

Conclusion

I'm certainly not an expert in the field of language design, so I welcome all comments and criticisms. My favorite feature of Go is its simplicity, and I hope I can help move the conversation around error handling to be about "What is the smallest change we need to solve this problem?"

I believe that the frustration around error handling in Go is around the verbosity of the if err != nil check, and therefore the smallest change we must make is one that eliminates as many of these if checks as possible while still allowing for easy to read, easy to write, and easy to debug code. We don't need to eliminate them all. We just need enough to make Go a little bit more fun to write in.

Thanks for reading!

Metadata

Metadata

Assignees

No one assigned

    Labels

    FrozenDueToAgeLanguageChangeSuggested changes to the Go languageProposalWaitingForInfoIssue is not actionable because of missing required information, which needs to be provided.error-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